Coverage for src/lilbee/cli/tui/widgets/task_bar_controller.py: 100%

290 statements  

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

1"""TaskBarController and the per-task ProgressReporter.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6import logging 

7import threading 

8import time 

9from collections.abc import Callable, Collection, Iterator 

10from contextlib import contextmanager 

11from enum import StrEnum 

12from typing import TYPE_CHECKING, Any 

13 

14from textual.app import App 

15 

16from lilbee.catalog.formatting import download_task_name 

17from lilbee.cli.tui import messages as msg 

18from lilbee.cli.tui.task_queue import CancelOrigin, Task, TaskQueue, TaskStatus, TaskType 

19from lilbee.cli.tui.thread_safe import call_from_thread 

20from lilbee.crawler import bootstrap_chromium, chromium_installed 

21from lilbee.runtime import asyncio_loop 

22from lilbee.runtime.cancellation import TaskCancelledError 

23from lilbee.runtime.progress import EventType, SetupProgressEvent 

24 

25if TYPE_CHECKING: 

26 from lilbee.catalog import CatalogModel 

27 

28log = logging.getLogger(__name__) 

29 

30# Each download runs in its own child process (catalog.download_process), so 

31# transfers neither share a xet session nor die with a cancelled sibling. 

32_DOWNLOAD_CONCURRENCY = 4 

33_BYTES_PER_MB = 1024 * 1024 

34# Seconds the running tasks get, together, to unwind and end their workers at app exit. 

35_EXIT_STOP_BUDGET_S = 5.0 

36 

37 

38class TaskOutcome(StrEnum): 

39 """How a task terminated. Passed from worker thread to finalizer.""" 

40 

41 DONE = "done" 

42 FAILED = "failed" 

43 CANCELLED = "cancelled" 

44 

45 

46class ProgressReporter: 

47 """Thread-safe handle a worker uses to report progress and check cancellation. 

48 

49 The worker only sees this object; it never touches ``self.app``, 

50 ``call_from_thread``, or any screen. Writes to the lock-protected 

51 ``TaskQueue`` so updates survive any UI navigation. 

52 """ 

53 

54 def __init__(self, controller: TaskBarController, task: Task) -> None: 

55 self._controller = controller 

56 # The row itself, not its id: a cancel marks this object, and a cleared history 

57 # drops only the queue's reference to it, so the cancel still reaches the run. 

58 self._task = task 

59 

60 @property 

61 def task_id(self) -> str: 

62 return self._task.task_id 

63 

64 def is_set(self) -> bool: 

65 """True once this task is cancelled, by the user or at exit; the signal a run polls.""" 

66 return self._task.status is TaskStatus.CANCELLED 

67 

68 def cancelled_by_user(self) -> bool: 

69 """True once the user cancelled this task; a stop at app exit is not a user cancel.""" 

70 return self._task.cancel_origin is CancelOrigin.USER 

71 

72 def check_cancelled(self) -> None: 

73 """Raise ``TaskCancelledError`` if the task was cancelled, by the user or at exit.""" 

74 if self.is_set(): 

75 raise TaskCancelledError 

76 

77 def update( 

78 self, progress: float, detail: str = "", *, indeterminate: bool | None = None 

79 ) -> None: 

80 """Write a progress snapshot to the shared queue. 

81 

82 Raises ``TaskCancelledError`` first if the UI cancelled the task, so 

83 callers can use ``update`` as both a progress write and a cancel 

84 checkpoint. 

85 """ 

86 self.check_cancelled() 

87 self._controller.queue.update_task( 

88 self._task.task_id, progress, detail, indeterminate=indeterminate 

89 ) 

90 

91 

92TaskTarget = Callable[[ProgressReporter], None] 

93 

94 

95class _TaskWorker(threading.Thread): 

96 """The worker thread of one task; it keeps the task's row for as long as it runs.""" 

97 

98 def __init__(self, controller: TaskBarController, task: Task) -> None: 

99 super().__init__( 

100 target=controller._run_task_worker, 

101 args=(task,), 

102 daemon=True, 

103 name=f"task-{task.task_id}", 

104 ) 

105 self.task = task 

106 

107 

108def _chromium_bootstrap_target(reporter: ProgressReporter) -> None: 

109 """Worker target for the SETUP task: run bootstrap_chromium with progress forwarding. 

110 

111 Module-level so ``TaskBarController.ensure_chromium`` stays short and 

112 tests can stub the target in isolation. 

113 """ 

114 

115 def _forward(event_type: EventType, data: Any) -> None: 

116 if event_type != EventType.SETUP_PROGRESS: 

117 return 

118 if not isinstance(data, SetupProgressEvent): 

119 return 

120 total = data.total_bytes or 0 

121 pct = int(data.downloaded_bytes * 100 / total) if total > 0 else 0 

122 mb = data.downloaded_bytes // _BYTES_PER_MB 

123 if total > 0: 

124 detail = msg.SETUP_CHROMIUM_DETAIL.format(done=mb, total=total // _BYTES_PER_MB) 

125 else: 

126 detail = msg.SETUP_CHROMIUM_DETAIL_UNKNOWN.format(done=mb) 

127 reporter.update(pct, detail) 

128 

129 asyncio_loop.run(bootstrap_chromium(on_progress=_forward)) 

130 

131 

132class TaskBarController: 

133 """App-level owner of the shared TaskQueue + all long-running work. 

134 

135 The controller is attached as ``app.task_bar`` during 

136 ``LilbeeApp.__init__``. All task lifecycle methods 

137 (add/update/complete/fail/cancel) go through here so every ``TaskBar`` 

138 widget sees the same state, and every long-running op is spawned by 

139 this controller: never by a screen that may dismiss mid-flight. 

140 """ 

141 

142 def __init__(self, app: App[Any]) -> None: 

143 self.app = app 

144 self.queue = TaskQueue(capacity={TaskType.DOWNLOAD.value: _DOWNLOAD_CONCURRENCY}) 

145 # task_id -> (target, on_success), from the hold that enqueues the row until its 

146 # worker ends; a row cancelled with no worker loses its entry at the cancel. 

147 self._task_targets: dict[str, tuple[TaskTarget, Callable[[], None] | None]] = {} 

148 # task_id -> its started worker thread, joined by stop_all at exit. 

149 self._workers: dict[str, _TaskWorker] = {} 

150 # Held to enqueue a task with its target, to promote a row with the start of its 

151 # worker, to start a detection, to begin the stop, and for every use of _workers. 

152 self._lock = threading.Lock() 

153 # Number of files in documents/ that are out of date with the store. 

154 # Set by start_detect_pending; read by TaskBar to render the 

155 # "N docs to sync · S to sync" hint when no live tasks are running. 

156 # Atomic int writes are safe under the GIL; the bar polls at 10 Hz. 

157 self.pending_sync_count: int = 0 

158 self._detect_thread: threading.Thread | None = None 

159 # Set once, by stop_all, in a lock hold; read only by _admission. 

160 self._stopped = False 

161 # Roles whose worker is currently in the spawn window (1-3 s cold 

162 # start). Surfaced as a single TaskBar hint instead of one toast 

163 # per role so the chat screen isn't drowned in implementation 

164 # detail on first prompt. 

165 self.spawning_roles: set[str] = set() 

166 # Called on the main thread once at each end of a task's work. 

167 self._work_end_listeners: list[Callable[[], None]] = [] 

168 

169 def subscribe_work_end(self, listener: Callable[[], None]) -> None: 

170 """Call *listener* on the main thread once at each end of a task's work. 

171 

172 The end is the exit of the task's worker, or the end of a row that has no worker. 

173 

174 A queue change cannot report this: a cancel ends the row, and the row's worker 

175 ends later with no further change to the queue. 

176 """ 

177 if listener not in self._work_end_listeners: 

178 self._work_end_listeners.append(listener) 

179 

180 def unsubscribe_work_end(self, listener: Callable[[], None]) -> None: 

181 """Stop calling *listener*; harmless if it is not subscribed.""" 

182 if listener in self._work_end_listeners: 

183 self._work_end_listeners.remove(listener) 

184 

185 def _work_ended(self, task_id: str) -> None: 

186 """Report the end of *task_id*'s work once: now, or when its worker exits if it has one.""" 

187 with self._lock: 

188 if task_id in self._workers: 

189 return 

190 for listener in list(self._work_end_listeners): 

191 try: 

192 listener() 

193 except Exception: 

194 # A bad listener must not reach the caller of a cancel or starve the next one. 

195 log.exception("A work-end listener raised") 

196 

197 def has_live_work(self, task_types: Collection[str]) -> bool: 

198 """True while a task of *task_types* is queued, active, or unwinding after a cancel. 

199 

200 A cancel takes the row off the queue at once, but its worker keeps writing until 

201 its next checkpoint, so a live worker counts as well as a live row. 

202 """ 

203 # Queued first: a row promoted between the two reads then shows as active. 

204 rows = self.queue.queued_tasks + self.queue.active_tasks 

205 if any(task.task_type in task_types for task in rows): 

206 return True 

207 with self._lock: 

208 workers = list(self._workers.values()) 

209 return any(w.is_alive() and w.task.task_type in task_types for w in workers) 

210 

211 def cancelling_tasks(self, task_types: Collection[str] | None = None) -> list[Task]: 

212 """Cancelled tasks whose worker is alive, read from the map that has_live_work reads.""" 

213 with self._lock: 

214 workers = list(self._workers.values()) 

215 return [ 

216 w.task 

217 for w in workers 

218 if w.is_alive() 

219 and w.task.status is TaskStatus.CANCELLED 

220 and (task_types is None or w.task.task_type in task_types) 

221 ] 

222 

223 def add_task( 

224 self, 

225 name: str, 

226 task_type: str, 

227 fn: Callable[[], None] | None = None, 

228 *, 

229 indeterminate: bool = False, 

230 ) -> str: 

231 """Enqueue a task. Returns the new task_id.""" 

232 return self.queue.enqueue( 

233 fn or (lambda: None), name, task_type, indeterminate=indeterminate 

234 ) 

235 

236 def update_task( 

237 self, 

238 task_id: str, 

239 progress: float, 

240 detail: str = "", 

241 *, 

242 indeterminate: bool | None = None, 

243 ) -> None: 

244 """Update progress and detail text for a task.""" 

245 self.queue.update_task(task_id, progress, detail, indeterminate=indeterminate) 

246 

247 def complete_task(self, task_id: str) -> None: 

248 """Mark a task done. Row lingers in history until the user clears it.""" 

249 task_type = self._task_type_of(task_id) 

250 self.queue.complete_task(task_id) 

251 self._after_done_hooks(task_type) 

252 self._advance_all(task_type) 

253 self._work_ended(task_id) 

254 

255 def fail_task(self, task_id: str, detail: str = "") -> None: 

256 """Mark a task failed. Row lingers in history until the user clears it.""" 

257 self.queue.fail_task(task_id, detail) 

258 self._advance_all(self._task_type_of(task_id)) 

259 self._work_ended(task_id) 

260 

261 def cancel_task(self, task_id: str) -> None: 

262 """Mark a task cancelled; its worker notices at the next cancel checkpoint. 

263 

264 A download worker polls the row's status and terminates its child 

265 process, which stops the bytes; other workers raise out of their next 

266 progress update. 

267 """ 

268 started = task_id in self._task_targets 

269 task_type = self._task_type_of(task_id) 

270 self._cancel(task_id, CancelOrigin.USER) 

271 if not started: 

272 # Rows put straight on the queue have no worker whose exit advances 

273 # it, so the cancel must. A started row either has a worker that 

274 # finalizes and advances when it exits, or is queued and holds no 

275 # slot worth freeing. 

276 self._advance_all(task_type) 

277 self._work_ended(task_id) 

278 

279 def _after_done_hooks(self, task_type: str | None) -> None: 

280 """Side effects triggered by a DONE completion. 

281 

282 Callable from both the direct ``complete_task`` convenience and 

283 the worker-thread ``_finalize_task`` path so every success route 

284 stays in sync. Does NOT advance the queue; each caller picks the 

285 advance strategy that fits its context (``_advance_all`` vs 

286 ``_try_start_next``). 

287 """ 

288 if task_type == TaskType.DOWNLOAD.value: 

289 self._notify_model_installed() 

290 elif task_type in (TaskType.SYNC.value, TaskType.WIKI.value): 

291 self.reload_wiki_screens() 

292 

293 def _task_type_of(self, task_id: str) -> str | None: 

294 task = self.queue.get_task(task_id) 

295 return task.task_type if task else None 

296 

297 def _advance_all(self, task_type: str | None) -> None: 

298 """Start the freed type's next task, then any other idle type's. 

299 

300 Promotion has to spawn the worker. A task advanced without one sits 

301 ACTIVE with no thread behind it, which renders as a live row that never 

302 progresses. 

303 """ 

304 if task_type: 

305 self._try_start_next(task_type) 

306 self._try_start_next(None) 

307 

308 def downloading_label_for(self, ref: str) -> str | None: 

309 """Return the task name if *ref*'s download is queued or active, else None. 

310 

311 ``ref`` is a model reference (catalog repo id or native GGUF 

312 ref); the helper maps it to the canonical 

313 :attr:`CatalogModel.display_name` and matches against 

314 in-flight DOWNLOAD tasks. The returned label is suitable for 

315 embedding in a user-facing toast. 

316 """ 

317 label = download_task_name(ref) 

318 if not label: 

319 return None 

320 for task in self.queue.active_tasks + self.queue.queued_tasks: 

321 if task.task_type == TaskType.DOWNLOAD.value and task.name == label: 

322 return task.name 

323 return None 

324 

325 def set_pending_sync(self, count: int) -> None: 

326 """Update the pending-sync count surfaced in the TaskBar hint.""" 

327 self.pending_sync_count = max(count, 0) 

328 

329 def clear_pending_sync(self) -> None: 

330 """Drop the pending hint. Called when sync starts so the bar shows live progress instead.""" 

331 self.pending_sync_count = 0 

332 

333 def mark_role_spawning(self, role: str) -> None: 

334 """Add *role* to the set of workers whose pool process is starting.""" 

335 self.spawning_roles.add(role) 

336 

337 def mark_role_spawned(self, role: str) -> None: 

338 """Drop *role* from the spawn-in-progress set; harmless if already absent.""" 

339 self.spawning_roles.discard(role) 

340 

341 def start_detect_pending(self) -> None: 

342 """Run the cheap sync-detection (filesystem walk + hash compare) on a daemon thread. 

343 

344 Writes the result via ``set_pending_sync``. No-op if a detect job 

345 is already running or the exit stop began. Errors are logged and 

346 silently swallowed: a failed detect just leaves the previous count 

347 in place rather than blocking the UI. 

348 """ 

349 thread = threading.Thread( 

350 target=self._run_detect_pending, daemon=True, name="detect-pending" 

351 ) 

352 with self._admission() as admitted: 

353 running = self._detect_thread is not None and self._detect_thread.is_alive() 

354 if admitted and not running: 

355 self._detect_thread = thread 

356 thread.start() 

357 

358 def _run_detect_pending(self) -> None: 

359 # Local import: lilbee.data.ingest pulls in lancedb + the embedder 

360 # transitively; the TUI shouldn't pay for that just to import the 

361 # task bar widget. 

362 from lilbee.data.ingest import detect_pending 

363 

364 try: 

365 count = detect_pending() 

366 except Exception: 

367 log.warning("detect_pending failed", exc_info=True) 

368 return 

369 self.set_pending_sync(count) 

370 

371 def ensure_chromium(self, on_ready: Callable[[], None]) -> None: 

372 """Kick off a Chromium bootstrap if missing, then call ``on_ready``. 

373 

374 If Chromium is already installed, ``on_ready`` runs immediately on 

375 the caller's thread. Otherwise a single SETUP task is enqueued 

376 that runs ``bootstrap_chromium``; on success the controller 

377 invokes ``on_ready`` on the worker thread via the task's 

378 ``on_success`` hook. On failure the SETUP task surfaces as FAILED 

379 and ``on_ready`` is NOT called (the follow-up work shouldn't 

380 proceed against a missing browser). 

381 

382 bb-wq8g: the on_ready hook is how callers like ``_do_crawl`` chain 

383 their real work behind the one-time bootstrap. 

384 """ 

385 if chromium_installed(): 

386 on_ready() 

387 return 

388 

389 self.start_task( 

390 msg.SETUP_CHROMIUM_NAME, 

391 TaskType.SETUP, 

392 _chromium_bootstrap_target, 

393 indeterminate=False, 

394 on_success=on_ready, 

395 ) 

396 

397 def start_task( 

398 self, 

399 name: str, 

400 task_type: TaskType, 

401 target: TaskTarget, 

402 *, 

403 indeterminate: bool = False, 

404 on_success: Callable[[], None] | None = None, 

405 dedupe_key: str | None = None, 

406 ) -> str: 

407 """Enqueue a task, spawn its worker, return task_id. 

408 

409 The *target* receives a ``ProgressReporter`` as its only argument. 

410 It should periodically call ``reporter.update(percent, detail)`` and 

411 may call ``reporter.check_cancelled()`` to cooperatively abort. 

412 

413 On success (target returns normally) the queue marks the task DONE 

414 and ``on_success`` (if provided) runs after on the same worker 

415 thread. On ``TaskCancelledError`` the task is marked CANCELLED. On any 

416 other exception the task is marked FAILED with ``str(exc)`` as 

417 detail. Rows linger in the Task Center under their final status 

418 until the user presses capital ``C`` to clear; the bottom bar 

419 flashes the outcome once and then hides when idle. 

420 

421 Per-type capacity in ``TaskQueue`` (1 for every type) controls 

422 concurrency: a second task of the same type queues behind the first. 

423 A task started once the exit stop began is cancelled as an exit. 

424 """ 

425 # The row and its target enter in one hold, so no promotion sees one without the other. 

426 with self._admission() as admitted: 

427 task_id = self.queue.enqueue( 

428 lambda: None, 

429 name, 

430 task_type.value, 

431 indeterminate=indeterminate, 

432 dedupe_key=dedupe_key, 

433 ) 

434 if task_id in self._task_targets: 

435 # Deduplicated: the live task keeps its original target. 

436 return task_id 

437 if admitted: 

438 self._task_targets[task_id] = (target, on_success) 

439 else: 

440 self.queue.cancel(task_id, CancelOrigin.EXIT) 

441 self._try_start_next(task_type.value) 

442 return task_id 

443 

444 @contextmanager 

445 def _admission(self) -> Iterator[bool]: 

446 """Hold the lock to admit a task, a promotion or a detection; False once the stop began.""" 

447 with self._lock: 

448 yield not self._stopped 

449 

450 def _try_start_next(self, task_type: str | None) -> None: 

451 """Promote queued tasks into free slots: of *task_type*, or of every type for None.""" 

452 while self._promote_one(task_type): 

453 pass 

454 

455 def _promote_one(self, task_type: str | None) -> bool: 

456 """Promote one due task and start its worker in one admitted hold; False if none starts.""" 

457 with self._admission() as admitted: 

458 # Once the exit stop began a row stays queued, and the stop cancels it. 

459 if not admitted: 

460 return False 

461 task = self.queue.advance(task_type) 

462 if task is None: 

463 return False 

464 # A row put straight on the queue has no target and gets no worker. 

465 if task.task_id in self._task_targets: 

466 worker = _TaskWorker(self, task) 

467 # The map holds started threads only; a worker that ends at once waits here. 

468 worker.start() 

469 self._workers[task.task_id] = worker 

470 return True 

471 

472 def _cancel(self, task_id: str, origin: CancelOrigin) -> None: 

473 """Cancel the row; with no worker to drop its target, the target goes here.""" 

474 self.queue.cancel(task_id, origin) 

475 with self._lock: 

476 if task_id not in self._workers: 

477 self._task_targets.pop(task_id, None) 

478 

479 def stop_all(self, budget_s: float = _EXIT_STOP_BUDGET_S) -> None: 

480 """Cancel every task as an exit, unwind their coroutines on the loop, then join the workers. 

481 

482 Draining the loop cancels each task's pending await, so a sync waiting on 

483 a model page runs its cleanup now, while the executors still accept work. 

484 The drain and the joins spend from the one *budget_s*. 

485 """ 

486 # Every start is whole before this hold or sees the stop after it. 

487 with self._lock: 

488 self._stopped = True 

489 deadline = time.monotonic() + budget_s 

490 for task in [*self.queue.queued_tasks, *self.queue.active_tasks]: 

491 self._cancel(task.task_id, CancelOrigin.EXIT) 

492 asyncio_loop.shutdown(budget_s) 

493 with self._lock: 

494 threads = [*self._workers.values(), self._detect_thread] 

495 for thread in threads: 

496 if thread is not None: 

497 thread.join(max(0.0, deadline - time.monotonic())) 

498 

499 def _run_task_worker(self, task: Task) -> None: 

500 """Body of the daemon worker thread.""" 

501 task_id = task.task_id 

502 entry = self._task_targets.get(task_id) 

503 if entry is None: 

504 return 

505 target, on_success = entry 

506 task_type = task.task_type 

507 reporter = ProgressReporter(self, task) 

508 try: 

509 target(reporter) 

510 except (TaskCancelledError, asyncio.CancelledError) as exc: 

511 log.info("Task %s cancelled", task_id) 

512 self._post_finalize(task_id, TaskOutcome.CANCELLED, str(exc), task_type) 

513 except Exception as exc: 

514 log.warning("Task %s failed: %s", task_id, exc) 

515 self._post_finalize(task_id, TaskOutcome.FAILED, str(exc), task_type) 

516 else: 

517 self._post_finalize(task_id, TaskOutcome.DONE, "", task_type) 

518 if on_success is not None: 

519 try: 

520 on_success() 

521 except Exception: 

522 log.warning("on_success for %s raised", task_id, exc_info=True) 

523 finally: 

524 self._task_targets.pop(task_id, None) 

525 with self._lock: 

526 self._workers.pop(task_id, None) 

527 # After the pop, so a listener that asks has_live_work sees this worker gone. 

528 call_from_thread(self.app, self._work_ended, task_id) 

529 

530 def _post_finalize( 

531 self, task_id: str, outcome: TaskOutcome, detail: str, task_type: str | None 

532 ) -> None: 

533 """Marshal finalization back to the main thread. 

534 

535 Main-thread execution matters because ``set_timer`` (used for the 

536 flash-then-remove cycle) isn't safe from workers. ``call_from_thread`` 

537 targets ``self.app``: the App is long-lived; screens are not. 

538 """ 

539 call_from_thread(self.app, self._finalize_task, task_id, outcome, detail, task_type) 

540 

541 def _finalize_task( 

542 self, task_id: str, outcome: TaskOutcome, detail: str, task_type: str | None 

543 ) -> None: 

544 """Mark the queue state, refresh dependents, promote next queued task. 

545 

546 Runs on the main thread. Atomically: free the active slot, notify 

547 anything downstream that needs a repaint (e.g. model dropdowns 

548 after a download lands), and advance the queue. Rows stay in 

549 history; the bottom bar flash expires on its own. Users clear 

550 finished rows from the Task Center manually. 

551 """ 

552 if outcome is TaskOutcome.DONE: 

553 self.queue.complete_task(task_id) 

554 self._after_done_hooks(task_type) 

555 elif outcome is TaskOutcome.FAILED: 

556 self.queue.fail_task(task_id, detail) 

557 elif outcome is TaskOutcome.CANCELLED: 

558 self.queue.cancel(task_id) 

559 if detail: 

560 self.queue.set_cancel_reason(task_id, detail) 

561 if task_type: 

562 self._try_start_next(task_type) 

563 

564 def _notify_model_installed(self) -> None: 

565 """Refresh any ChatScreen's ModelBar so the new model is selectable. 

566 

567 The dropdowns are built once on mount from the registry; without 

568 this nudge, a freshly-downloaded model only appears after the 

569 user reopens the screen. A bar that is not mounted yet is no longer 

570 this caller's problem: refresh_model_bar is a no-op until the bar 

571 exists, and the bar scans on its own mount. 

572 """ 

573 # Late import to avoid a circular (ChatScreen imports this module). 

574 from lilbee.cli.tui.screens.chat import ChatScreen 

575 

576 for screen in self.app.screen_stack: 

577 # screen_stack is typed Screen[Any]; narrow at runtime to 

578 # locate the one screen that owns the ModelBar. 

579 if isinstance(screen, ChatScreen): 

580 screen.refresh_model_bar() 

581 break 

582 

583 def reload_wiki_screens(self) -> None: 

584 """Rescan open wiki and drafts screens after a task rewrote pages or drafts on disk.""" 

585 from textual.css.query import QueryError 

586 

587 from lilbee.cli.tui.screens.wiki import WikiScreen 

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

589 

590 for screen in self.app.screen_stack: 

591 # screen_stack is typed Screen[Any]; narrow at runtime to the 

592 # wiki views that expose a reload. 

593 if isinstance(screen, WikiScreen | WikiDraftsScreen): 

594 try: 

595 screen.reload() 

596 except QueryError: 

597 log.debug("Wiki screen not mounted yet; skipping reload", exc_info=True) 

598 

599 def start_download( 

600 self, 

601 model: CatalogModel, 

602 *, 

603 allow_unsupported: bool = False, 

604 on_success: Callable[[], None] | None = None, 

605 ) -> str: 

606 """Enqueue a download; ``on_success`` runs on the worker thread once the file is on disk. 

607 

608 Re-requesting a download that is already queued or running returns that 

609 task's id and starts nothing. 

610 """ 

611 return self.start_task( 

612 model.display_name, 

613 TaskType.DOWNLOAD, 

614 lambda reporter: _download_target(reporter, model, allow_unsupported=allow_unsupported), 

615 on_success=on_success, 

616 dedupe_key=download_key(model), 

617 ) 

618 

619 def pending_download(self, model: CatalogModel) -> Task | None: 

620 """Return the queued-or-active download for *model*, if there is one.""" 

621 return self.queue.find_pending(TaskType.DOWNLOAD.value, download_key(model)) 

622 

623 

624def download_key(model: CatalogModel) -> str: 

625 """Identity of a pull: repo plus filename, since one repo ships several quants.""" 

626 return f"{model.hf_repo}::{model.gguf_filename}" 

627 

628 

629def _download_target( 

630 reporter: ProgressReporter, model: CatalogModel, *, allow_unsupported: bool = False 

631) -> None: 

632 """``start_task`` target for a HuggingFace model download. 

633 

634 Translates ``PermissionError`` into the gated-repo friendly message and 

635 ``UnsupportedArchError`` into a user-facing arch-mismatch message so 

636 every call site gets consistent error UX. 

637 """ 

638 from lilbee.app.models import pull_model_data 

639 from lilbee.catalog import DownloadProgress 

640 from lilbee.catalog.compat import UnsupportedArchError 

641 from lilbee.catalog.types import ModelSource 

642 

643 def _on_progress(p: DownloadProgress) -> None: 

644 reporter.update(p.percent, f"{model.display_name}: {p.detail}") 

645 

646 try: 

647 pull_model_data( 

648 model.ref, 

649 ModelSource.NATIVE, 

650 on_update=_on_progress, 

651 allow_unsupported=allow_unsupported, 

652 cancel=reporter, 

653 ) 

654 except PermissionError as exc: 

655 raise RuntimeError(msg.CATALOG_GATED_REPO.format(name=model.display_name)) from exc 

656 except UnsupportedArchError as exc: 

657 raise RuntimeError( 

658 f"Architecture {exc.architecture!r} not supported by this lilbee build." 

659 ) from exc