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

257 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-06-28 01:01 +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, cast 

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 

16from textual.widgets import Input, TextArea 

17 

18from lilbee.app.services import get_services 

19from lilbee.app.settings import apply_settings_update 

20from lilbee.app.themes import DARK_THEMES 

21from lilbee.cli.tui import messages as msg 

22from lilbee.cli.tui.commands import LilbeeCommandProvider 

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

24from lilbee.core.config import cfg 

25from lilbee.providers.worker.transport import WorkerRole 

26 

27log = logging.getLogger(__name__) 

28 

29_DEFAULT_THEME = "rose-pine" # muted, low-glare; easier on the eyes than the warmer themes 

30_CHAT_SCREEN_NAME = "chat" 

31# Long enough that a model-fallback notice is readable before it fades. 

32_FALLBACK_TOAST_TIMEOUT_S = 10.0 

33 

34 

35def _view_screen_name(view_name: str) -> str: 

36 """Stable install_screen identifier for a top-level view (lower-cased).""" 

37 return view_name.lower() 

38 

39 

40def _make_catalog() -> Screen: 

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

42 

43 return CatalogScreen() 

44 

45 

46def _make_status() -> Screen: 

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

48 

49 return StatusScreen() 

50 

51 

52def _make_settings() -> Screen: 

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

54 

55 return SettingsScreen() 

56 

57 

58def _make_tasks() -> Screen: 

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

60 

61 return TaskCenter() 

62 

63 

64def _make_wiki() -> Screen: 

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

66 

67 return WikiScreen() 

68 

69 

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

71 "Catalog": _make_catalog, 

72 "Status": _make_status, 

73 "Settings": _make_settings, 

74 "Tasks": _make_tasks, 

75} 

76 

77 

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

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

80 views = dict(_BASE_VIEWS) 

81 if cfg.wiki: 

82 views["Wiki"] = _make_wiki 

83 return views 

84 

85 

86class LilbeeApp(App[None]): 

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

88 

89 TITLE = "lilbee" 

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

91 ENABLE_COMMAND_PALETTE = True 

92 COMMANDS = {LilbeeCommandProvider} # noqa: RUF012 

93 

94 _NAV_GROUP = Binding.Group("Navigate") 

95 

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

97 # ``?`` is non-priority so a focused TextArea (chat input in INSERT 

98 # mode) can swallow it and type the literal character. F1 / Ctrl+H 

99 # remain priority routes that always open help, even mid-typing. 

100 Binding("question_mark", "push_help", "Help", show=False), 

101 Binding("f1", "push_help", "Help", show=True, priority=True), 

102 Binding("ctrl+h", "push_help", "Help", show=False, priority=True), 

103 Binding("escape", "dismiss_help_if_open", "Close help", show=False, priority=True), 

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

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

106 Binding("f4", "toggle_lilbee_path", "Path/Name", show=True), 

107 # Non-priority so Chat's "focus_commands" and Catalog's 

108 # "focus_search" still win on those screens. Fires only on 

109 # screens that don't bind slash themselves, routing the user 

110 # to Chat with the slash already typed. 

111 Binding("slash", "global_slash_to_chat", "Command", show=False), 

112 # priority=True so a focused TextArea cannot swallow the bracket 

113 # under stress (multi-key send-keys etc.); type literal brackets 

114 # via Shift+[ / Shift+] which produce { / } and bypass these. 

115 Binding( 

116 "left_square_bracket", 

117 "nav_prev", 

118 "Prev", 

119 show=True, 

120 group=_NAV_GROUP, 

121 priority=True, 

122 ), 

123 Binding( 

124 "right_square_bracket", 

125 "nav_next", 

126 "Next", 

127 show=True, 

128 group=_NAV_GROUP, 

129 priority=True, 

130 ), 

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

132 Binding("S", "run_sync", "Sync", show=False, priority=True), 

133 ] 

134 

135 def __init__(self, *, initial_view: str | None = None) -> None: 

136 super().__init__() 

137 self._initial_view = initial_view 

138 self.active_view = msg.DEFAULT_VIEW 

139 self._switching = False 

140 self._theme_index = 0 

141 # Names of non-Chat screens already installed via install_screen. 

142 # Subsequent visits switch by name to reuse the same instance, 

143 # so Footer / signal / worker wiring runs once per session. 

144 self._installed_screen_names: set[str] = set() 

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

146 self.provider_availability_changed_signal: Signal[tuple[str, object]] = Signal( 

147 self, "provider_availability_changed" 

148 ) 

149 from lilbee.cli.tui.widgets.task_bar_controller import TaskBarController 

150 

151 self.task_bar = TaskBarController(self) 

152 

153 def compose(self) -> ComposeResult: 

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

155 

156 # Test seam: the TUI test fixtures subclass LilbeeApp and set this to True 

157 # so on_mount short-circuits before the heavyweight setup (model 

158 # canonicalization, ChatScreen install, signal subscriptions, sync probe). 

159 # Production never sets it. See tests/_lilbee_app_test_host.py. 

160 _test_skip_auto_init: ClassVar[bool] = False 

161 

162 def on_mount(self) -> None: 

163 if self._test_skip_auto_init: 

164 return 

165 self._canonicalize_persisted_models() 

166 self.title = f"lilbee: {cfg.chat_model}" 

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

168 # picked last session, not always the default. 

169 persisted = cfg.theme or _DEFAULT_THEME 

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

171 self._sync_theme_index_to_current() 

172 

173 self.settings_changed_signal.subscribe(self, self._fan_out_provider_availability) 

174 self._wire_worker_pool_notifications() 

175 

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

177 

178 chat = ChatScreen() 

179 self.install_screen(chat, name=_CHAT_SCREEN_NAME) 

180 self.push_screen(_CHAT_SCREEN_NAME) 

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

182 self.switch_view(self._initial_view) 

183 # Cheap detection only: filesystem walk + hash compare. The user 

184 # initiates sync explicitly via S or the command palette. 

185 self.task_bar.start_detect_pending() 

186 

187 def _wire_worker_pool_notifications(self) -> None: 

188 """Surface worker spawn lifecycle in the bottom TaskBar. 

189 

190 Worker spawns happen on the pool runtime thread, not the TUI's main 

191 loop, so the listeners marshal back via :meth:`call_from_thread` 

192 before mutating controller state. A single TaskBar hint covers all 

193 in-flight roles instead of one toast per role; the chat surface is 

194 for user content, not implementation detail. 

195 """ 

196 

197 def _on_spawning(role: WorkerRole) -> None: 

198 self.call_from_thread(self.task_bar.mark_role_spawning, role.value) 

199 

200 def _on_spawned(role: WorkerRole) -> None: 

201 self.call_from_thread(self.task_bar.mark_role_spawned, role.value) 

202 

203 get_services().add_pool_listener(on_spawning=_on_spawning, on_spawned=_on_spawned) 

204 

205 def _canonicalize_persisted_models(self) -> None: 

206 """Swap stale persisted refs to a working fallback, persist, and log once.""" 

207 from lilbee.modelhub.model_manager import ( 

208 ValidationResult, 

209 canonicalize_chat_model, 

210 canonicalize_embedding_model, 

211 ) 

212 

213 for canon, field, label in ( 

214 (canonicalize_chat_model(), "chat_model", "Chat"), 

215 (canonicalize_embedding_model(), "embedding_model", "Embedding"), 

216 ): 

217 if canon.status == ValidationResult.OK: 

218 continue 

219 reason = canon.reason or msg.MODEL_REASON_DEFAULT 

220 

221 if canon.original == canon.effective: 

222 # Nothing to fall back to: keep the ref; the chat screen opens the wizard. 

223 notice = msg.MODEL_UNUSABLE_OPENING_SETUP.format( 

224 label=label, original=canon.original, reason=reason 

225 ) 

226 log.warning(notice) 

227 self.notify(notice, severity="warning", timeout=_FALLBACK_TOAST_TIMEOUT_S) 

228 continue 

229 

230 # A rejected swap (validation or disk error) must not be fatal at startup. 

231 try: 

232 apply_settings_update({field: canon.effective}) 

233 except (ValueError, OSError): 

234 log.warning( 

235 msg.MODEL_FALLBACK_FAILED.format( 

236 label=label, 

237 original=canon.original, 

238 effective=canon.effective, 

239 reason=reason, 

240 ), 

241 exc_info=True, 

242 ) 

243 continue 

244 notice = msg.MODEL_FALLBACK_NOTICE.format( 

245 label=label, original=canon.original, effective=canon.effective, reason=reason 

246 ) 

247 log.warning(notice) 

248 self.notify(notice, severity="warning", timeout=_FALLBACK_TOAST_TIMEOUT_S) 

249 

250 def _fan_out_provider_availability(self, payload: tuple[str, object]) -> None: 

251 """Republish on provider_availability_changed_signal when an API key changes.""" 

252 from lilbee.core.config.keys import PROVIDER_API_KEYS 

253 

254 key, value = payload 

255 if key in PROVIDER_API_KEYS: 

256 self.provider_availability_changed_signal.publish((key, value)) 

257 

258 def action_cycle_theme(self) -> None: 

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

260 name = DARK_THEMES[self._theme_index] 

261 self._apply_and_persist_theme(name) 

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

263 

264 def action_toggle_lilbee_path(self) -> None: 

265 """Flip the status-bar pill between the friendly name and the data-root path.""" 

266 self.set_setting("show_lilbee_path", not cfg.show_lilbee_path) 

267 

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

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

270 if name in self.available_themes: 

271 self._apply_and_persist_theme(name) 

272 self._sync_theme_index_to_current() 

273 

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

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

276 

277 self.theme = name 

278 apply_settings_update({"theme": name}) 

279 

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

281 """Persist an active model ref through the shared write boundary. 

282 

283 Refs whose download is still queued or active are refused before the 

284 boundary runs, so a half-pulled file cannot land in a model slot. 

285 """ 

286 

287 downloading = self.task_bar.downloading_label_for(value) 

288 if downloading is not None: 

289 self.notify( 

290 msg.MODEL_BEING_DOWNLOADED.format(name=downloading), 

291 severity="warning", 

292 ) 

293 return 

294 try: 

295 apply_settings_update({key: value}) 

296 except ValueError as exc: 

297 self.notify(msg.MODEL_ASSIGN_REJECTED.format(error=exc), severity="error") 

298 return 

299 self.settings_changed_signal.publish((key, getattr(cfg, key))) 

300 

301 def set_setting(self, key: str, value: object) -> None: 

302 """Apply a writable / model-role setting through the boundary, then fan out to the UI. 

303 

304 Raises ``ValueError`` for keys outside ``WRITABLE_CONFIG_FIELDS | MODEL_ROLE_FIELDS`` 

305 or values rejected by pydantic validation. Callers either catch and toast or let it 

306 propagate. 

307 """ 

308 

309 apply_settings_update({key: value}) 

310 normalized = getattr(cfg, key) 

311 if key == "theme" and isinstance(normalized, str) and normalized in self.available_themes: 

312 self.theme = normalized 

313 self._sync_theme_index_to_current() 

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

315 

316 def _sync_theme_index_to_current(self) -> None: 

317 """Align cycle index with the active theme.""" 

318 try: 

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

320 except ValueError: 

321 self._theme_index = 0 

322 

323 async def action_quit(self) -> None: 

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

325 get_services().cancel_inference() 

326 

327 if not self.task_bar.queue.is_empty: 

328 active = self.task_bar.queue.active_task 

329 if active: 

330 self.task_bar.cancel_task(active.task_id) 

331 self.notify(msg.APP_CANCELLED) 

332 return 

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

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

335 

336 screen = self.screen 

337 if isinstance(screen, SetupWizard): 

338 screen.action_cancel() 

339 return 

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

341 screen.action_cancel_stream() 

342 return 

343 self.exit() 

344 

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

346 """Switch to a named view, installing each screen at most once. 

347 

348 Guards against concurrent switches via ``self._switching`` so 

349 rapid keypresses don't corrupt the screen stack. 

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

351 """ 

352 if self._switching: 

353 return 

354 self._switching = True 

355 

356 if view_name == "Chat": 

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

358 

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

360 self.switch_screen(_CHAT_SCREEN_NAME) 

361 # Already on Chat, just update state below. 

362 else: 

363 factory = get_views().get(view_name) 

364 if factory is None: 

365 self._switching = False 

366 return 

367 screen_name = _view_screen_name(view_name) 

368 if screen_name not in self._installed_screen_names: 

369 self.install_screen(factory(), name=screen_name) 

370 self._installed_screen_names.add(screen_name) 

371 self.switch_screen(screen_name) 

372 

373 def _finish() -> None: 

374 self.active_view = view_name 

375 self._switching = False 

376 # ViewTabs.on_mount captured active_view before this callback 

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

378 with contextlib.suppress(NoMatches): 

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

380 

381 self.call_later(_finish) 

382 

383 def action_push_help(self) -> None: 

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

385 self.action_hide_help_panel() 

386 else: 

387 self.action_show_help_panel() 

388 

389 def action_command_palette(self) -> None: 

390 """Ctrl+P: cycle the chat dropdown if visible, else open the palette.""" 

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

392 from lilbee.cli.tui.widgets.autocomplete import CompletionOverlay 

393 

394 screen = self.screen 

395 if isinstance(screen, ChatScreen): 

396 try: 

397 overlay = screen.query_one("#completion-overlay", CompletionOverlay) 

398 except NoMatches: 

399 overlay = None 

400 if overlay is not None and overlay.is_visible: 

401 screen.action_complete_prev() 

402 return 

403 super().action_command_palette() 

404 

405 def action_dismiss_help_if_open(self) -> None: 

406 """Esc dismisses the HelpPanel when it is open; otherwise no-op. 

407 

408 Without this, focus inside the panel could prevent ``?`` from 

409 toggling it back off and the user had no key to escape with. 

410 Bubble the Escape so screens can still receive it when no panel 

411 is mounted. 

412 """ 

413 from textual.actions import SkipAction 

414 

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

416 self.action_hide_help_panel() 

417 return 

418 raise SkipAction() 

419 

420 def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: 

421 """Hide ``t Tasks`` from the footer while a text input is focused. 

422 

423 ``t`` is not a priority binding, so a focused ``Input`` / ``TextArea`` 

424 (the chat prompt in INSERT mode, a catalog/settings search box) eats 

425 it as a literal character. Showing ``t Tasks`` there would lie. 

426 """ 

427 # isinstance: a focused Input/TextArea consumes printable keys before 

428 # non-priority screen/app bindings see them, so `t` types a literal there. 

429 if action == "open_tasks" and isinstance(self.focused, (Input, TextArea)): 

430 return False 

431 return super().check_action(action, parameters) 

432 

433 def action_open_tasks(self) -> None: 

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

435 self.switch_view("Tasks") 

436 

437 def action_global_slash_to_chat(self) -> None: 

438 """Route a slash typed on a non-slash-bound screen back to Chat's prompt. 

439 

440 Lets the user type ``/setup`` from Settings/Tasks/etc. without 

441 the next character (``s``, ``t``, ...) hitting a global single-key 

442 binding before the slash command can compose. 

443 """ 

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

445 

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

447 self.switch_view("Chat") 

448 # Defer the prompt focus until after switch_view's call_later 

449 # _finish has updated active_view, so the chat input is mounted 

450 # and ready when we prefill it. 

451 self.call_later(self._prefill_chat_command) 

452 

453 def _prefill_chat_command(self) -> None: 

454 """Focus the chat input and seed it with a leading slash.""" 

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

456 

457 if isinstance(self.screen, ChatScreen): 

458 self.screen.action_focus_commands() 

459 

460 def action_run_sync(self) -> None: 

461 """Trigger an explicit document sync from any screen (S key). 

462 

463 The TaskBar hint is rendered globally, so the trigger must work 

464 everywhere. Routes to the registered ChatScreen which owns the 

465 ``_run_sync`` orchestration; switches to the Chat view first if 

466 not already there so the user can watch progress. 

467 """ 

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

469 

470 if isinstance(self.screen, ChatScreen): 

471 self.screen._run_sync() 

472 return 

473 try: 

474 chat = self.get_screen(_CHAT_SCREEN_NAME, ChatScreen) 

475 except KeyError: 

476 return 

477 self.switch_view("Chat") 

478 

479 def _start() -> None: 

480 if isinstance(self.screen, ChatScreen): 

481 chat._run_sync() 

482 

483 self.call_later(_start) 

484 

485 def action_nav_prev(self) -> None: 

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

487 view_names = msg.get_nav_views() 

488 current_idx = view_names.index(self.active_view) 

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

490 

491 def action_nav_next(self) -> None: 

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

493 view_names = msg.get_nav_views() 

494 current_idx = view_names.index(self.active_view) 

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

496 

497 

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

499 """Route model writes through LilbeeApp.set_active_model.""" 

500 cast(LilbeeApp, host_app).set_active_model(key, value) 

501 

502 

503def apply_setting(host_app: App[Any], key: str, value: object) -> None: 

504 """Route non-model settings writes through LilbeeApp.set_setting.""" 

505 cast(LilbeeApp, host_app).set_setting(key, value)