Coverage for src/lilbee/cli/tui/screens/startup_gate.py: 100%
62 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-08 09:20 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-08 09:20 +0000
1"""Startup splash: the lilbee wordmark while the services container builds."""
3from __future__ import annotations
5import logging
6from collections.abc import Callable
8from textual import work
9from textual.app import ComposeResult
10from textual.containers import Vertical
11from textual.screen import Screen
12from textual.widgets import ProgressBar, Static
13from textual.worker import get_current_worker
15from lilbee.app.services import peek_services
16from lilbee.cli.tui import messages as msg
17from lilbee.cli.tui.app import LilbeeApp
18from lilbee.runtime.bee_logo import BEE_LINES
20log = logging.getLogger(__name__)
22_LOGO = "\n".join(BEE_LINES)
25class StartupGate(Screen[None]):
26 """Holds the screen until readiness settles, then hands over to the landing view.
28 The engine itself loads in the background after the handover; a prompt sent
29 before it is ready waits inside its own answer bubble with live progress.
30 """
32 CSS_PATH = "startup_gate.tcss"
34 # Lilbee always hosts screens on a LilbeeApp, so narrowing the type lets
35 # reveal_landing resolve without reflection.
36 app: LilbeeApp # type: ignore[assignment]
38 # The app reference every worker and UI hop uses: ``self.app`` reads a
39 # contextvar that plain threads never carry and then walks ``_parent``,
40 # which raises NoActiveAppError the moment the gate detaches. Captured
41 # once in on_mount, on the UI thread, where resolution is certain.
42 _ui_app: LilbeeApp
44 def compose(self) -> ComposeResult:
45 with Vertical(id="gate-body"):
46 yield Static(_LOGO, id="gate-logo")
47 yield ProgressBar(total=None, show_eta=False, show_percentage=False, id="gate-bar")
48 yield Static(msg.STARTUP_PREPARING, id="gate-status")
50 def on_mount(self) -> None:
51 """Capture the app for the workers, then retire the launcher's splash.
53 The splash animates over the blank alt-screen right up to this moment,
54 so the wordmark never leaves the terminal. Dismissal waits on the
55 subprocess, so it runs off-thread; the refresh afterwards repaints
56 anything a final splash frame may have touched.
57 """
58 self._ui_app = self.app
59 self._retire_splash()
61 @work(thread=True, name="splash_retire", exit_on_error=False)
62 def _retire_splash(self) -> None:
63 from lilbee.runtime.splash import dismiss
65 dismiss()
66 self._marshal(self._repaint)
68 def _repaint(self) -> None:
69 """Repaint anything a final splash frame may have scribbled over."""
70 self.refresh()
72 def start_boot(self) -> None:
73 """Work out what the app can serve, off the UI thread, then hand over.
75 One path even when the container is already built (a second TUI in the
76 same process, a test host): the setup answer still decides whether the
77 handover happens. The thread hop also keeps the screen switch out of
78 on_mount, which stalls Textual.
79 """
80 self._boot_worker()
82 @work(thread=True, name="startup_gate", exit_on_error=False)
83 def _boot_worker(self) -> None:
84 """Settle the refs, settle readiness, build the container, hand over.
86 Canonicalization runs first: it can swap a stale ref for a working one,
87 or adopt an installed model into an unconfigured role, which decides
88 the landing view. An already built container skips it.
90 ``settle_landing`` blocks until the app has recorded the answer, so
91 the handover cannot read a readiness flag that has yet to be written.
93 Building the container spawns the role servers, so it belongs on this
94 thread, behind the loading bar.
95 """
96 try:
97 if peek_services() is None:
98 self._ui_app.canonicalize_persisted_models()
99 self._ui_app.settle_landing()
100 self._ui_app.adopt_services()
101 except Exception as exc:
102 # Any failure to prepare the app leaves the user with no engine.
103 # Show it and hand them the rest of the TUI rather than a dead screen.
104 log.exception("startup gate could not prepare the app")
105 self._marshal(self._fail, str(exc))
106 return
107 self._marshal(self._release)
109 def _stopping(self) -> bool:
110 """True once the worker was cancelled or the gate left the screen."""
111 worker = get_current_worker()
112 return worker.is_cancelled or not self.is_mounted
114 def _marshal(self, callback: Callable[..., None], *args: object) -> None:
115 """Hop to the UI thread, unless the app is already tearing down."""
116 if self._stopping():
117 return
118 self._ui_app.call_from_thread(callback, *args)
120 def _fail(self, error: str) -> None:
121 """Surface a failed start and hand the user to the rest of the TUI to fix it."""
122 self._ui_app.notify(msg.STARTUP_FAILED.format(error=error), severity="error", timeout=8)
123 self._release()
125 def _release(self) -> None:
126 """Hand the screen to the landing view, unless something else has taken it.
128 reveal_landing switches whatever screen is on top, so a gate that resolved
129 after another screen opened above it would replace that screen instead of
130 itself. No widget lookup here either: the gate can resolve before compose
131 has mounted its children, and a missed query would strand the user.
132 """
133 if self._ui_app.screen is not self:
134 return
135 self._ui_app.reveal_landing()