Coverage for src/lilbee/cli/tui/screens/startup_gate.py: 100%
60 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +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 def compose(self) -> ComposeResult:
39 with Vertical(id="gate-body"):
40 yield Static(_LOGO, id="gate-logo")
41 yield ProgressBar(total=None, show_eta=False, show_percentage=False, id="gate-bar")
42 yield Static(msg.STARTUP_PREPARING, id="gate-status")
44 def on_mount(self) -> None:
45 """Retire the launcher's splash now that Textual is painting.
47 The splash animates over the blank alt-screen right up to this moment,
48 so the wordmark never leaves the terminal. Dismissal waits on the
49 subprocess, so it runs off-thread; the refresh afterwards repaints
50 anything a final splash frame may have touched.
51 """
52 self._retire_splash()
54 @work(thread=True, name="splash_retire", exit_on_error=False)
55 def _retire_splash(self) -> None:
56 from lilbee.runtime.splash import dismiss
58 dismiss()
59 self._marshal(self._repaint)
61 def _repaint(self) -> None:
62 """Repaint anything a final splash frame may have scribbled over."""
63 self.refresh()
65 def start_boot(self) -> None:
66 """Work out what the app can serve, off the UI thread, then hand over.
68 One path even when the container is already built (a second TUI in the
69 same process, a test host): the setup answer still decides whether the
70 handover happens. The thread hop also keeps the screen switch out of
71 on_mount, which stalls Textual.
72 """
73 self._boot_worker()
75 @work(thread=True, name="startup_gate", exit_on_error=False)
76 def _boot_worker(self) -> None:
77 """Settle the refs, settle readiness, build the container, hand over.
79 Canonicalization runs first: it can swap a stale ref for a working one,
80 or adopt an installed model into an unconfigured role, which decides
81 the landing view. An already built container skips it.
83 ``settle_landing`` blocks until the app has recorded the answer, so
84 the handover cannot read a readiness flag that has yet to be written.
86 Building the container spawns the role servers, so it belongs on this
87 thread, behind the loading bar.
88 """
89 try:
90 if peek_services() is None:
91 self.app.canonicalize_persisted_models()
92 self.app.settle_landing()
93 self.app.adopt_services()
94 except Exception as exc:
95 # Any failure to prepare the app leaves the user with no engine.
96 # Show it and hand them the rest of the TUI rather than a dead screen.
97 log.exception("startup gate could not prepare the app")
98 self._marshal(self._fail, str(exc))
99 return
100 self._marshal(self._release)
102 def _stopping(self) -> bool:
103 """True once the worker was cancelled or the gate left the screen."""
104 worker = get_current_worker()
105 return worker.is_cancelled or not self.is_mounted
107 def _marshal(self, callback: Callable[..., None], *args: object) -> None:
108 """Hop to the UI thread, unless the app is already tearing down."""
109 if self._stopping():
110 return
111 self.app.call_from_thread(callback, *args)
113 def _fail(self, error: str) -> None:
114 """Surface a failed start and hand the user to the rest of the TUI to fix it."""
115 self.app.notify(msg.STARTUP_FAILED.format(error=error), severity="error", timeout=8)
116 self._release()
118 def _release(self) -> None:
119 """Hand the screen to the landing view, unless something else has taken it.
121 reveal_landing switches whatever screen is on top, so a gate that resolved
122 after another screen opened above it would replace that screen instead of
123 itself. No widget lookup here either: the gate can resolve before compose
124 has mounted its children, and a missed query would strand the user.
125 """
126 if self.app.screen is not self:
127 return
128 self.app.reveal_landing()