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

1"""Startup splash: the lilbee wordmark while the services container builds.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6from collections.abc import Callable 

7 

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 

14 

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 

19 

20log = logging.getLogger(__name__) 

21 

22_LOGO = "\n".join(BEE_LINES) 

23 

24 

25class StartupGate(Screen[None]): 

26 """Holds the screen until readiness settles, then hands over to the landing view. 

27 

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 """ 

31 

32 CSS_PATH = "startup_gate.tcss" 

33 

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] 

37 

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") 

43 

44 def on_mount(self) -> None: 

45 """Retire the launcher's splash now that Textual is painting. 

46 

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() 

53 

54 @work(thread=True, name="splash_retire", exit_on_error=False) 

55 def _retire_splash(self) -> None: 

56 from lilbee.runtime.splash import dismiss 

57 

58 dismiss() 

59 self._marshal(self._repaint) 

60 

61 def _repaint(self) -> None: 

62 """Repaint anything a final splash frame may have scribbled over.""" 

63 self.refresh() 

64 

65 def start_boot(self) -> None: 

66 """Work out what the app can serve, off the UI thread, then hand over. 

67 

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() 

74 

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. 

78 

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. 

82 

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. 

85 

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) 

101 

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 

106 

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) 

112 

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() 

117 

118 def _release(self) -> None: 

119 """Hand the screen to the landing view, unless something else has taken it. 

120 

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()