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

169 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-04-29 19:16 +0000

1"""Main Textual app for lilbee TUI.""" 

2 

3from __future__ import annotations 

4 

5import contextlib 

6import logging 

7from collections.abc import Callable 

8from pathlib import Path 

9from typing import Any, ClassVar 

10 

11from textual.app import App, ComposeResult 

12from textual.binding import Binding, BindingType 

13from textual.css.query import NoMatches 

14from textual.screen import Screen 

15from textual.signal import Signal 

16 

17from lilbee import settings 

18from lilbee.cli.tui import messages as msg 

19from lilbee.cli.tui.commands import LilbeeCommandProvider 

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

21from lilbee.config import cfg 

22from lilbee.services import get_services, reset_services 

23 

24log = logging.getLogger(__name__) 

25 

26_DEFAULT_THEME = "gruvbox" # warm retro CRT aesthetic 

27_CHAT_SCREEN_NAME = "chat" 

28DARK_THEMES = ( 

29 "monokai", 

30 "dracula", 

31 "tokyo-night", 

32 "nord", 

33 "gruvbox", 

34 "catppuccin-mocha", 

35 "catppuccin-frappe", 

36 "atom-one-dark", 

37 "rose-pine", 

38 "solarized-dark", 

39 "textual-dark", 

40) 

41 

42 

43def _make_catalog() -> Screen: 

44 from lilbee.cli.tui.screens.catalog import CatalogScreen 

45 

46 return CatalogScreen() 

47 

48 

49def _make_status() -> Screen: 

50 from lilbee.cli.tui.screens.status import StatusScreen 

51 

52 return StatusScreen() 

53 

54 

55def _make_settings() -> Screen: 

56 from lilbee.cli.tui.screens.settings import SettingsScreen 

57 

58 return SettingsScreen() 

59 

60 

61def _make_tasks() -> Screen: 

62 from lilbee.cli.tui.screens.task_center import TaskCenter 

63 

64 return TaskCenter() 

65 

66 

67def _make_wiki() -> Screen: 

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

69 

70 return WikiScreen() 

71 

72 

73_BASE_VIEWS: dict[str, Callable[[], Screen]] = { 

74 "Catalog": _make_catalog, 

75 "Status": _make_status, 

76 "Settings": _make_settings, 

77 "Tasks": _make_tasks, 

78} 

79 

80 

81def get_views() -> dict[str, Callable[[], Screen]]: 

82 """Return the active view factories, including wiki when enabled.""" 

83 views = dict(_BASE_VIEWS) 

84 if cfg.wiki: 

85 views["Wiki"] = _make_wiki 

86 return views 

87 

88 

89def _on_settings_changed_evict_cache(payload: tuple[str, object]) -> None: 

90 """Drop loaded-model state when a load-affecting setting changes.""" 

91 # Lazy: llama_cpp_provider's transitive imports cost ~500ms. 

92 from lilbee.providers.llama_cpp_provider import LOAD_AFFECTING_KEYS 

93 

94 key, _value = payload 

95 if key in LOAD_AFFECTING_KEYS: 

96 get_services().provider.invalidate_load_cache() 

97 

98 

99class LilbeeApp(App[None]): 

100 """Full-screen TUI for lilbee knowledge base.""" 

101 

102 TITLE = "lilbee" 

103 CSS_PATH = Path(__file__).parent / "app.tcss" 

104 ENABLE_COMMAND_PALETTE = True 

105 COMMANDS = {LilbeeCommandProvider} # noqa: RUF012 

106 

107 _NAV_GROUP = Binding.Group("Navigate") 

108 

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

110 Binding("question_mark", "push_help", "Help", show=True), 

111 Binding("f1", "push_help", "Help", show=False), 

112 Binding("ctrl+h", "push_help", "Help", show=False), 

113 Binding("ctrl+t", "cycle_theme", "Theme", show=True), 

114 Binding("t", "open_tasks", "Tasks", show=True), 

115 # priority=True is required: even though NavAwareInput lets [ and ] 

116 # bubble past Input.check_consume_key, Textual's focused Input still 

117 # handles printable keys in _on_key before a non-priority ancestor 

118 # binding can fire. Both NavAwareInput and priority=True are needed. 

119 Binding( 

120 "left_square_bracket", 

121 "nav_prev", 

122 "Prev", 

123 show=True, 

124 group=_NAV_GROUP, 

125 priority=True, 

126 ), 

127 Binding( 

128 "right_square_bracket", 

129 "nav_next", 

130 "Next", 

131 show=True, 

132 group=_NAV_GROUP, 

133 priority=True, 

134 ), 

135 Binding("ctrl+c", "quit", "Quit", show=True, priority=True), 

136 ] 

137 

138 def __init__(self, *, auto_sync: bool = False, initial_view: str | None = None) -> None: 

139 super().__init__() 

140 self._auto_sync = auto_sync 

141 self._initial_view = initial_view 

142 self.active_view = msg.DEFAULT_VIEW 

143 self._switching = False 

144 self._theme_index = 0 

145 self.last_quit_time: float = 0.0 

146 self.settings_changed_signal: Signal[tuple[str, object]] = Signal(self, "settings_changed") 

147 from lilbee.cli.tui.widgets.task_bar import TaskBarController 

148 

149 self.task_bar = TaskBarController(self) 

150 

151 def compose(self) -> ComposeResult: 

152 yield from () # screens compose their own ViewTabs + Footer 

153 

154 def on_mount(self) -> None: 

155 self.title = f"lilbee — {cfg.chat_model}" 

156 # Restore the persisted theme so the TUI opens in whatever the user 

157 # picked last session, not always the gruvbox default. 

158 persisted = cfg.theme or _DEFAULT_THEME 

159 self.theme = persisted if persisted in self.available_themes else _DEFAULT_THEME 

160 self._sync_theme_index_to_current() 

161 

162 self.settings_changed_signal.subscribe(self, _on_settings_changed_evict_cache) 

163 

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

165 

166 chat = ChatScreen(auto_sync=self._auto_sync) 

167 self.install_screen(chat, name=_CHAT_SCREEN_NAME) 

168 self.push_screen(_CHAT_SCREEN_NAME) 

169 if self._initial_view and self._initial_view != msg.DEFAULT_VIEW: 

170 self.switch_view(self._initial_view) 

171 

172 def action_cycle_theme(self) -> None: 

173 self._theme_index = (self._theme_index + 1) % len(DARK_THEMES) 

174 name = DARK_THEMES[self._theme_index] 

175 self._apply_and_persist_theme(name) 

176 self.notify(msg.THEME_SET.format(name=name)) 

177 

178 def set_theme(self, name: str) -> None: 

179 """Set theme by name (used by /theme command). Persists across sessions.""" 

180 if name in self.available_themes: 

181 self._apply_and_persist_theme(name) 

182 self._sync_theme_index_to_current() 

183 

184 def _apply_and_persist_theme(self, name: str) -> None: 

185 """Apply *name* live and write it to config.toml.""" 

186 self.theme = name 

187 cfg.theme = name 

188 settings.set_value(cfg.data_root, "theme", name) 

189 

190 def set_active_model(self, key: str, value: str) -> None: 

191 """Single write boundary for active model refs; persists the 

192 post-validator value so subscribers see the normalized form.""" 

193 setattr(cfg, key, value) 

194 normalized = getattr(cfg, key) 

195 settings.set_value(cfg.data_root, key, normalized) 

196 self.settings_changed_signal.publish((key, normalized)) 

197 

198 def _sync_theme_index_to_current(self) -> None: 

199 """Align the cycle index with the active theme so Ctrl+T moves from there.""" 

200 try: 

201 self._theme_index = DARK_THEMES.index(self.theme) 

202 except ValueError: 

203 self._theme_index = 0 

204 

205 async def action_quit(self) -> None: 

206 """Context-aware Ctrl+C: cancel active task > cancel stream > quit. 

207 On second Ctrl+C (within 2s), force-exits via os._exit to handle 

208 cases where the GIL is held by native code. 

209 """ 

210 import time 

211 

212 now = time.monotonic() 

213 if now - self.last_quit_time < 2.0: 

214 self._force_quit() 

215 return 

216 self.last_quit_time = now 

217 

218 if not self.task_bar.queue.is_empty: 

219 active = self.task_bar.queue.active_task 

220 if active: 

221 self.task_bar.cancel_task(active.task_id) 

222 self.notify(msg.APP_CANCELLED) 

223 return 

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

225 from lilbee.cli.tui.screens.setup import SetupWizard 

226 

227 screen = self.screen 

228 if isinstance(screen, SetupWizard): 

229 screen.action_cancel() 

230 return 

231 if isinstance(screen, ChatScreen) and screen.streaming: 

232 screen.action_cancel_stream() 

233 return 

234 self.exit() 

235 

236 def _force_quit(self) -> None: 

237 """Force-exit when normal quit is blocked (e.g. GIL held by native code).""" 

238 import os 

239 

240 with contextlib.suppress(Exception): 

241 reset_services() 

242 os._exit(1) 

243 

244 def switch_view(self, view_name: str) -> None: 

245 """Switch to a named view via lazy screen factories. 

246 

247 Guards against concurrent switches: ``switch_screen`` is async 

248 (processed on the next event-loop tick) but callers read 

249 ``active_view`` synchronously. Without a guard, rapid keypresses 

250 queue conflicting switches that corrupt the screen stack. 

251 ``active_view`` is updated after the switch completes. 

252 """ 

253 if self._switching: 

254 return 

255 self._switching = True 

256 

257 if view_name == "Chat": 

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

259 

260 if not isinstance(self.screen, ChatScreen): 

261 self.switch_screen(_CHAT_SCREEN_NAME) 

262 # Already on Chat, just update state below. 

263 else: 

264 factory = get_views().get(view_name) 

265 if factory is None: 

266 self._switching = False 

267 return 

268 self.switch_screen(factory()) 

269 

270 def _finish() -> None: 

271 self.active_view = view_name 

272 self._switching = False 

273 # ViewTabs.on_mount captured active_view before this callback 

274 # runs, so the highlight would lag by one step without this push. 

275 with contextlib.suppress(NoMatches): 

276 self.screen.query_one(ViewTabs).active_view = view_name 

277 

278 self.call_later(_finish) 

279 

280 def action_push_help(self) -> None: 

281 if self.screen.query("HelpPanel"): 

282 self.action_hide_help_panel() 

283 else: 

284 self.action_show_help_panel() 

285 

286 def action_open_tasks(self) -> None: 

287 """Jump to the Task Center screen (t key).""" 

288 self.switch_view("Tasks") 

289 

290 def action_nav_prev(self) -> None: 

291 """Navigate to previous view ([ key).""" 

292 view_names = msg.get_nav_views() 

293 current_idx = view_names.index(self.active_view) 

294 self.switch_view(view_names[(current_idx - 1) % len(view_names)]) 

295 

296 def action_nav_next(self) -> None: 

297 """Navigate to next view (] key).""" 

298 view_names = msg.get_nav_views() 

299 current_idx = view_names.index(self.active_view) 

300 self.switch_view(view_names[(current_idx + 1) % len(view_names)]) 

301 

302 

303def apply_active_model(host_app: App[Any], key: str, value: str) -> None: 

304 """Route model writes through LilbeeApp.set_active_model; bare-App fallback for tests.""" 

305 if isinstance(host_app, LilbeeApp): 

306 host_app.set_active_model(key, value) 

307 return 

308 setattr(cfg, key, value) 

309 settings.set_value(cfg.data_root, key, getattr(cfg, key))