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

437 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +0000

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

2 

3from __future__ import annotations 

4 

5import contextlib 

6import logging 

7import os 

8import sys 

9from collections.abc import Callable, Sequence 

10from pathlib import Path, PurePath 

11from typing import TYPE_CHECKING, Any, ClassVar, cast 

12 

13from rich.console import Console 

14from textual import work 

15from textual.app import App, ComposeResult 

16from textual.await_complete import AwaitComplete 

17from textual.binding import Binding, BindingType 

18from textual.command import CommandPalette 

19from textual.css.query import NoMatches 

20from textual.filter import LineFilter 

21from textual.reactive import reactive 

22from textual.screen import Screen 

23from textual.signal import Signal 

24from textual.widgets import Input, TextArea 

25 

26from lilbee.app.services import get_services, peek_services 

27from lilbee.app.settings import apply_settings_update 

28from lilbee.app.setup_state import chat_ready, embedding_ready 

29from lilbee.app.themes import DARK_THEMES 

30from lilbee.cli.tui import messages as msg 

31from lilbee.cli.tui.color_compat import ( 

32 EightBitPalette, 

33 draws_block_bars, 

34 draws_block_glyphs, 

35 needs_eight_bit, 

36 resolve_term_program, 

37) 

38from lilbee.cli.tui.commands import LilbeeCommandProvider 

39from lilbee.cli.tui.screens.command_palette import LilbeeCommandPalette 

40from lilbee.cli.tui.thread_safe import call_from_thread 

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

42from lilbee.config_meta import MODEL_ROLE_FIELDS 

43from lilbee.core.config import cfg 

44from lilbee.providers.roles import WorkerRole 

45 

46if TYPE_CHECKING: 

47 from lilbee.app.services import Services 

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

49 from lilbee.cli.tui.screens.startup_gate import StartupGate 

50 

51log = logging.getLogger(__name__) 

52 

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

54_CHAT_SCREEN_NAME = "chat" 

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

56_FALLBACK_TOAST_TIMEOUT_S = 10.0 

57 

58 

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

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

61 return view_name.lower() 

62 

63 

64def _make_catalog() -> Screen: 

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

66 

67 return CatalogScreen() 

68 

69 

70def _make_status() -> Screen: 

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

72 

73 return StatusScreen() 

74 

75 

76def _make_settings() -> Screen: 

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

78 

79 return SettingsScreen() 

80 

81 

82def _make_tasks() -> Screen: 

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

84 

85 return TaskCenter() 

86 

87 

88def _make_wiki() -> Screen: 

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

90 

91 return WikiScreen() 

92 

93 

94def _make_fleet() -> Screen: 

95 from lilbee.cli.tui.screens.fleet import FleetScreen 

96 

97 return FleetScreen() 

98 

99 

100def _make_sessions() -> Screen: 

101 from lilbee.cli.tui.screens.sessions import SessionsScreen 

102 

103 return SessionsScreen() 

104 

105 

106# Screen factory per managed view name (Chat is special-cased in switch_view and 

107# has no factory). The active set + order + wiki gate come from msg.get_nav_views, 

108# so the view universe lives in exactly one place (messages.ALL_NAV_VIEWS). 

109_VIEW_FACTORIES: dict[str, Callable[[], Screen]] = { 

110 msg.CATALOG_VIEW: _make_catalog, 

111 "Status": _make_status, 

112 "Settings": _make_settings, 

113 "Tasks": _make_tasks, 

114 "Wiki": _make_wiki, 

115 "Fleet": _make_fleet, 

116 "Sessions": _make_sessions, 

117} 

118 

119 

120def _import_chat_stack() -> None: 

121 """Pull in the chat screen's module graph, the TUI's heaviest import.""" 

122 import lilbee.cli.tui.screens.chat # noqa: F401 - imported for its side effect 

123 

124 

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

126 """Return the active view factories, derived from the nav view list.""" 

127 return {name: _VIEW_FACTORIES[name] for name in msg.get_nav_views() if name in _VIEW_FACTORIES} 

128 

129 

130class LilbeeApp(App[None]): 

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

132 

133 TITLE = "lilbee" 

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

135 # Restates Textual's own block-based borders in box-drawing. Loaded only 

136 # where the terminal needs it; see __init__. 

137 SAFE_CSS_PATH = Path(__file__).parent / "app_safe.tcss" 

138 ENABLE_COMMAND_PALETTE = True 

139 COMMANDS = {LilbeeCommandProvider} # noqa: RUF012 

140 

141 # The app row is [ and ] to move between views, plus Help and Quit. Every 

142 # other app key is help-panel only, which lists every non-system binding whatever 

143 # its ``show``. A group may hold one action pressed in two directions, where 

144 # a single label still tells the whole truth, and never keys that do 

145 # different things: five destinations behind one "Views" label named none of 

146 # them. Textual groups CONSECUTIVE runs of shown bindings, so a group's 

147 # members stay adjacent. 

148 _NAV_GROUP = Binding.Group("Views") 

149 

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

151 # ``?`` is the only key for help. Non-priority on purpose: a focused 

152 # text field consumes printable keys, which both types the literal 

153 # character and takes the key out of the footer row, so no guard here is 

154 # needed to keep the row honest. ChatInput additionally routes ``?`` on 

155 # an EMPTY prompt to this action, so an untouched prompt still opens 

156 # help. F1 is gone; one advertised key is enough. 

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

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

159 # Guarded like open_tasks in check_action: a focused text input 

160 # types the literal letter instead. 

161 # Help-panel only: the view tabs run across the top of every screen and are 

162 # clickable, so a footer cell per destination is a second copy of 

163 # something already on screen. [ and ] move between them. 

164 Binding("c", "open_chat", "Chat", show=False), 

165 Binding("m", "open_catalog", "Models", show=False), 

166 Binding("t", "open_tasks", "Tasks", show=False), 

167 # These two keep their cells: they open a drawer beside the current 

168 # screen rather than navigating, so unlike the jumps above they do 

169 # something the tab strip cannot, and nothing else on screen says so. 

170 Binding("ctrl+g", "toggle_fleet", "Fleet", show=True, priority=True), 

171 Binding("ctrl+o", "toggle_sessions", "Sessions", show=True, priority=True), 

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

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

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

175 Binding( 

176 "left_square_bracket", 

177 "nav_prev", 

178 "Prev", 

179 show=True, 

180 group=_NAV_GROUP, 

181 priority=True, 

182 ), 

183 Binding( 

184 "right_square_bracket", 

185 "nav_next", 

186 "Next", 

187 show=True, 

188 group=_NAV_GROUP, 

189 priority=True, 

190 ), 

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

192 # Off the row, like f4 below: cycling the theme is not something a user 

193 # needs advertised on every screen, and the row is for getting around. 

194 Binding("ctrl+t", "cycle_theme", "Theme", show=False), 

195 # Hidden: a title-bar display toggle is not worth a permanent footer 

196 # cell on all twelve screens. show=False only drops it from the footer 

197 # row -- the help panel lists every non-system binding regardless -- so 

198 # the key stays discoverable. 

199 Binding("f4", "toggle_lilbee_path", "Path/Name", show=False), 

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

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

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

203 # to Chat with the slash already typed. 

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

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

206 ] 

207 

208 # Per-role readiness, settled by the startup gate before it hands over any 

209 # screen and re-answered whenever a model role is reassigned. They drive 

210 # empty states and the landing view; no view is gated on them. Reactive so 

211 # screens can watch them instead of polling. 

212 chat_is_ready: reactive[bool] = reactive(True) 

213 embedding_is_ready: reactive[bool] = reactive(True) 

214 

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

216 # Both terminal questions are answered once, here: resolve_term_program can 

217 # shell out to tmux, and get_line_filters runs per widget per repaint. The 

218 # glyph answer must also land before super() so the stylesheet list is 

219 # complete when Textual reads it. 

220 color_system = Console().color_system 

221 term_program = resolve_term_program(os.environ) 

222 self._plain_glyphs = not draws_block_glyphs(color_system, term_program) 

223 # Prime the process-wide answer the bar renderers read (same predicate, 

224 # same inputs), so no repaint pays for the tmux probe. 

225 draws_block_bars() 

226 # A terminal that cannot tile partial-block glyphs also gets the sheet 

227 # restating Textual's own block borders. 

228 self._eight_bit_filter = ( 

229 EightBitPalette() if needs_eight_bit(color_system, term_program) else None 

230 ) 

231 css: list[str | PurePath] = [self.CSS_PATH] 

232 if self._plain_glyphs: 

233 css.append(self.SAFE_CSS_PATH) 

234 super().__init__(css_path=css) 

235 self._initial_view = initial_view 

236 self.active_view = msg.DEFAULT_VIEW 

237 # The view the user came from; go_back returns here so q/Escape mean 

238 # "back", not "Chat", on every top-level view. 

239 self._previous_view: str | None = None 

240 self._switching = False 

241 self._theme_index = 0 

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

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

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

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

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

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

248 self, "provider_availability_changed" 

249 ) 

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

251 

252 self.task_bar = TaskBarController(self) 

253 

254 def compose(self) -> ComposeResult: 

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

256 

257 def get_css_variables(self) -> dict[str, str]: 

258 """Textual's variables, plus the border style the terminal can actually draw. 

259 

260 `tall` and `thick` are built from partial block glyphs, which segment in 

261 fonts that do not draw them cell-exact. Carrying the style in a variable 

262 keeps one switch here instead of a second copy of every rule: a capable 

263 terminal keeps the block rails lilbee is drawn with, and only a terminal 

264 that needs it falls back to box-drawing. 

265 """ 

266 variables = super().get_css_variables() 

267 variables["rail"] = "solid" if self._plain_glyphs else "tall" 

268 variables["rail-heavy"] = "heavy" if self._plain_glyphs else "thick" 

269 return variables 

270 

271 def get_line_filters(self) -> Sequence[LineFilter]: 

272 """Textual's filters, plus the 256-color correction where the terminal needs it. 

273 

274 Added for a terminal that reduces to 256 colors, where Rich's own reduction 

275 collapses the theme's dark surfaces, and for Terminal.app, which claims 

276 truecolor it cannot render. See color_compat. A terminal that genuinely has 

277 truecolor gets no filter and renders byte-identically to before. 

278 

279 Textual calls this per widget per repaint, so it only reads the decision 

280 made in __init__. 

281 """ 

282 filters = list(super().get_line_filters()) 

283 if self._eight_bit_filter is not None: 

284 filters.append(self._eight_bit_filter) 

285 return filters 

286 

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

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

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

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

291 _test_skip_auto_init: ClassVar[bool] = False 

292 

293 async def on_mount(self) -> None: 

294 # The app's own signal graph is part of being a working app, not 

295 # "heavyweight auto-init": wiring it before the test-skip guard lets a 

296 # test observe app-level signals without booting the startup gate, whose 

297 # wait is a timing window that wedges loaded CI runners. 

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

299 self.settings_changed_signal.subscribe(self, self._recheck_models_on_model_change) 

300 if self._test_skip_auto_init: 

301 return 

302 # Paint the gate before any other work so the terminal is never blank 

303 # between the splash handing over and the first screen appearing. Nothing 

304 # slower than widget mounting may run before the first frame: model 

305 # canonicalization does disk and network probes, so it lives in the 

306 # gate's boot worker, off this thread. 

307 from lilbee.cli.tui.screens.startup_gate import StartupGate 

308 

309 gate = StartupGate() 

310 # Awaited: the gate's boot worker treats an unmounted gate as "torn down", 

311 # so it must be mounted before start_boot can hand over. 

312 await self.push_screen(gate) 

313 self.title = msg.app_title(cfg.chat_model) 

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

315 # picked last session, not always the default. 

316 persisted = cfg.theme or _DEFAULT_THEME 

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

318 self._sync_theme_index_to_current() 

319 

320 # Chat's import graph is the TUI's heaviest; loading it here would hold 

321 # the first frame back for seconds on a cold disk, leaving the terminal 

322 # blank exactly where the gate should be. Paint first, then load. 

323 self.call_after_refresh(self._load_chat_screen, gate) 

324 

325 def _load_chat_screen(self, gate: StartupGate) -> None: 

326 """Install chat after the first frame, importing off-thread only when cold. 

327 

328 The worker exists for the cold-disk case where chat's module graph takes 

329 seconds to read; once the modules are in sys.modules the import is free, 

330 and the extra thread hop would only delay the handover. 

331 """ 

332 if "lilbee.cli.tui.screens.chat" in sys.modules: 

333 self._install_chat_screen(gate) 

334 return 

335 self._chat_import_worker(gate) 

336 

337 @work(thread=True, name="chat_import", exit_on_error=False) 

338 def _chat_import_worker(self, gate: StartupGate) -> None: 

339 try: 

340 _import_chat_stack() 

341 except Exception as exc: 

342 # Without chat the app has no home screen; exit loudly like the old 

343 # inline import did rather than stranding the user on the gate. 

344 log.exception("the chat screen failed to import") 

345 call_from_thread(self, self._exit_on_chat_import_failure, str(exc)) 

346 return 

347 call_from_thread(self, self._install_chat_screen, gate) 

348 

349 def _exit_on_chat_import_failure(self, error: str) -> None: 

350 """Leave the TUI with the import error where the user can read it.""" 

351 self.exit(return_code=1, message=msg.CHAT_STACK_FAILED.format(error=error)) 

352 

353 def _install_chat_screen(self, gate: StartupGate) -> None: 

354 """Install chat and start the gate's boot; runs once chat's modules exist.""" 

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

356 

357 chat = ChatScreen() 

358 self.install_screen(chat, name=_CHAT_SCREEN_NAME) 

359 gate.start_boot() 

360 

361 def reveal_landing(self) -> None: 

362 """Swap the startup gate for what the machine can serve. 

363 

364 A resolvable chat model lands on Chat; anything else lands on the 

365 Catalog, where models are installed. 

366 """ 

367 if self.chat_is_ready: 

368 self.switch_screen(_CHAT_SCREEN_NAME) 

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

370 self.switch_view(self._initial_view) 

371 else: 

372 self.switch_view(msg.CATALOG_VIEW) 

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

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

375 self.task_bar.start_detect_pending() 

376 

377 def settle_landing(self) -> None: 

378 """Answer per-role readiness and record it. 

379 

380 Blocks the calling thread until the answer is recorded on the UI 

381 thread, so a handover ordered after it cannot read a stale flag. 

382 """ 

383 call_from_thread(self, self._apply_readiness, chat_ready(), embedding_ready()) 

384 

385 @work(thread=True, name="setup_state", exit_on_error=False) 

386 def refresh_readiness(self) -> None: 

387 """Re-answer readiness off the UI thread, leaving the user where they are.""" 

388 chat = chat_ready() 

389 embedding = embedding_ready() 

390 if (chat or embedding) and peek_services() is None: 

391 # The first model landed after the gate stepped aside, so nothing 

392 # has built the container yet and this thread is the one that should. 

393 self.adopt_services() 

394 call_from_thread(self, self._apply_readiness, chat, embedding) 

395 

396 def adopt_services(self) -> None: 

397 """Build the services container and subscribe this app to it. 

398 

399 Never call from the UI thread: building spawns the role servers. Two 

400 workers can reach here at once during boot; the listeners only add to 

401 and discard from a set, so a double subscription changes nothing. 

402 """ 

403 self._wire_worker_pool_notifications(get_services()) 

404 

405 def _apply_readiness(self, chat: bool, embedding: bool) -> None: 

406 """Record the per-role readiness answers.""" 

407 self.chat_is_ready = chat 

408 self.embedding_is_ready = embedding 

409 

410 def _recheck_models_on_model_change(self, payload: tuple[str, object]) -> None: 

411 """Re-answer readiness whenever a model role is reassigned. 

412 

413 Every model write lands on the settings boundary, a download included, 

414 so one subscription covers every surface that assigns one. 

415 """ 

416 key, _value = payload 

417 if key in MODEL_ROLE_FIELDS: 

418 self.refresh_readiness() 

419 

420 def _wire_worker_pool_notifications(self, services: Services) -> None: 

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

422 

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

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

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

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

427 for user content, not implementation detail. 

428 

429 Takes the container instead of reaching for one: reaching for it builds 

430 it, which is ``adopt_services``' job and never the UI thread's. 

431 """ 

432 

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

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

435 

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

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

438 

439 services.add_pool_listener(on_spawning=_on_spawning, on_spawned=_on_spawned) 

440 

441 def canonicalize_persisted_models(self) -> None: 

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

443 

444 Canonicalization reads model files and can probe local model servers 

445 over HTTP/DNS, so the startup gate's boot worker calls this off the 

446 event loop before the services container builds; anything slower than 

447 widget mounting on the mount path delays the TUI's first frame. UI 

448 updates marshal back to the main thread. 

449 """ 

450 from lilbee.modelhub.model_manager import ( 

451 ValidationResult, 

452 canonicalize_chat_model, 

453 canonicalize_embedding_model, 

454 ) 

455 

456 chat_canon = canonicalize_chat_model() 

457 embedding_canon = canonicalize_embedding_model() 

458 for canon, field, label in ( 

459 (chat_canon, "chat_model", "Chat"), 

460 (embedding_canon, "embedding_model", "Embedding"), 

461 ): 

462 if canon.status == ValidationResult.OK: 

463 continue 

464 reason = canon.reason or msg.MODEL_REASON_DEFAULT 

465 

466 if canon.original == canon.effective: 

467 # Nothing to fall back to: keep the ref and let the catalog 

468 # landing be the single voice for "pick a model." A toast here 

469 # would just duplicate it, so log the reason as a breadcrumb 

470 # but don't surface it. An unconfigured role isn't even a 

471 # breadcrumb: there is nothing to report about a model nobody 

472 # chose. 

473 if canon.original: 

474 log.warning( 

475 msg.MODEL_UNUSABLE_NO_FALLBACK.format( 

476 label=label, original=canon.original, reason=reason 

477 ) 

478 ) 

479 continue 

480 

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

482 try: 

483 apply_settings_update({field: canon.effective}) 

484 except (ValueError, OSError): 

485 log.warning( 

486 msg.MODEL_FALLBACK_FAILED.format( 

487 label=label, 

488 original=canon.original, 

489 effective=canon.effective, 

490 reason=reason, 

491 ), 

492 exc_info=True, 

493 ) 

494 continue 

495 if not canon.original: 

496 # Adopting an installed model into an unconfigured role is the 

497 # expected path (models pulled before the TUI ever ran), not a 

498 # fallback worth a warning toast. 

499 log.info(msg.MODEL_ADOPTED_LOG.format(label=label, effective=canon.effective)) 

500 continue 

501 notice = msg.MODEL_FALLBACK_NOTICE.format( 

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

503 ) 

504 log.warning(notice) 

505 call_from_thread( 

506 self, self.notify, notice, severity="warning", timeout=_FALLBACK_TOAST_TIMEOUT_S 

507 ) 

508 call_from_thread(self, self._refresh_title) 

509 

510 def _refresh_title(self) -> None: 

511 """Re-derive the window title after canonicalization may have swapped the ref.""" 

512 self.title = msg.app_title(cfg.chat_model) 

513 

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

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

516 from lilbee.core.config.keys import PROVIDER_API_KEYS 

517 

518 key, value = payload 

519 if key in PROVIDER_API_KEYS: 

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

521 

522 def action_cycle_theme(self) -> None: 

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

524 name = DARK_THEMES[self._theme_index] 

525 self._apply_and_persist_theme(name) 

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

527 

528 def action_toggle_lilbee_path(self) -> None: 

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

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

531 

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

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

534 if name in self.available_themes: 

535 self._apply_and_persist_theme(name) 

536 self._sync_theme_index_to_current() 

537 

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

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

540 

541 self.theme = name 

542 apply_settings_update({"theme": name}) 

543 

544 def _reject_if_downloading(self, value: object) -> bool: 

545 """Toast and return True if *value* is a model ref still downloading, so a 

546 half-pulled file can't land in a model slot.""" 

547 if not isinstance(value, str): 

548 return False 

549 downloading = self.task_bar.downloading_label_for(value) 

550 if downloading is None: 

551 return False 

552 self.notify(msg.MODEL_BEING_DOWNLOADED.format(name=downloading), severity="warning") 

553 return True 

554 

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

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

557 

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

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

560 """ 

561 if self._reject_if_downloading(value): 

562 return 

563 try: 

564 apply_settings_update({key: value}) 

565 except ValueError as exc: 

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

567 return 

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

569 

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

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

572 

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

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

575 propagate. 

576 """ 

577 # A model-role ref still downloading must not land in a slot (parity with 

578 # set_active_model); toast and skip rather than half-pull. 

579 if key in MODEL_ROLE_FIELDS and self._reject_if_downloading(value): 

580 return 

581 apply_settings_update({key: value}) 

582 normalized = getattr(cfg, key) 

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

584 self.theme = normalized 

585 self._sync_theme_index_to_current() 

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

587 if key == "wiki" and normalized is False: 

588 self._offer_wiki_wipe() 

589 

590 def _offer_wiki_wipe(self) -> None: 

591 """Ask whether to delete what the wiki generated, now that it is off. 

592 

593 Lives on the setter rather than on the settings screen so every route 

594 that turns the wiki off (the settings editor, ``/set``) makes the same 

595 offer. Disabling stops new pages being written but removes nothing, so 

596 without this the pages stay on disk and their rows stay in the store. 

597 """ 

598 from lilbee.cli.tui.messages import WIKI_WIPE_DISABLED_MESSAGE, WIKI_WIPE_DISABLED_TITLE 

599 from lilbee.cli.tui.screens.wiki import confirm_wiki_wipe 

600 

601 confirm_wiki_wipe( 

602 self, 

603 title=WIKI_WIPE_DISABLED_TITLE, 

604 message=WIKI_WIPE_DISABLED_MESSAGE, 

605 notify_when_empty=False, 

606 ) 

607 

608 def _sync_theme_index_to_current(self) -> None: 

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

610 try: 

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

612 except ValueError: 

613 self._theme_index = 0 

614 

615 async def action_quit(self) -> None: 

616 """Context-aware Ctrl+C: cancel the foreground operation, else quit. 

617 

618 Only operations the user is actively watching (an in-flight chat 

619 stream) get the cancel-first treatment; a background task like an 

620 engine warm or a sync never swallows a quit. 

621 """ 

622 get_services().cancel_inference() 

623 

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

625 

626 screen = self.screen 

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

628 screen.action_cancel_stream() 

629 self.notify(msg.APP_QUIT_AGAIN_HINT) 

630 return 

631 self.exit() 

632 

633 def _view_is_refused(self, view_name: str) -> bool: 

634 """True when *view_name* cannot be entered now, having handled the refusal.""" 

635 if view_name == msg.SESSIONS_VIEW and not cfg.sessions_enabled: 

636 # The tab stays visible so the feature is discoverable, but opening it 

637 # while off shows why rather than an empty list. 

638 self._notify_sessions_disabled() 

639 return True 

640 return view_name != msg.DEFAULT_VIEW and get_views().get(view_name) is None 

641 

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

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

644 

645 Guards against concurrent switches via ``self._switching`` so rapid 

646 keypresses can't corrupt the screen stack. ``active_view`` is updated 

647 after the switch completes. 

648 """ 

649 if self._switching or self._view_is_refused(view_name): 

650 return 

651 self._switching = True 

652 if view_name != self.active_view: 

653 self._previous_view = self.active_view 

654 

655 awaitable: AwaitComplete | None = None 

656 if view_name == msg.DEFAULT_VIEW: 

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

658 

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

660 awaitable = self.switch_screen(_CHAT_SCREEN_NAME) 

661 # Already on Chat, just update state below. 

662 else: 

663 screen_name = _view_screen_name(view_name) 

664 if screen_name not in self._installed_screen_names: 

665 self.install_screen(get_views()[view_name](), name=screen_name) 

666 self._installed_screen_names.add(screen_name) 

667 awaitable = self.switch_screen(screen_name) 

668 

669 self.active_view = view_name 

670 # ViewTabs.on_mount captured active_view before this runs, so the 

671 # highlight would lag by one step without this push. 

672 with contextlib.suppress(NoMatches): 

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

674 

675 async def _release() -> None: 

676 # switch_screen updates the stack synchronously but finishes mounting 

677 # in a deferred AwaitComplete. Releasing the guard on the next tick 

678 # (the old call_later) let a rapid second nav re-enter switch_screen 

679 # mid-transition and pop an empty result-callback stack (a Textual 

680 # IndexError). Awaiting the transition first keeps the guard up for 

681 # the whole switch; call_next is flushed by the same event loop, so a 

682 # single completed switch still releases promptly. 

683 if awaitable is not None: 

684 await awaitable 

685 self._switching = False 

686 

687 self.call_next(_release) 

688 

689 def action_push_help(self) -> None: 

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

691 self.action_hide_help_panel() 

692 else: 

693 self.action_show_help_panel() 

694 

695 def action_command_palette(self) -> None: 

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

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

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

699 

700 screen = self.screen 

701 if isinstance(screen, ChatScreen): 

702 try: 

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

704 except NoMatches: 

705 overlay = None 

706 if overlay is not None and overlay.is_visible: 

707 screen.action_complete_prev() 

708 return 

709 # Textual's own action hard-codes its CommandPalette; the subclass carries 

710 # lilbee's search icon. isinstance rather than CommandPalette.is_open, 

711 # which is typed for App[object] and so rejects lilbee's own app type. 

712 if self.use_command_palette and not isinstance(self.screen, CommandPalette): 

713 self.push_screen(LilbeeCommandPalette(id="--command-palette")) 

714 

715 def action_dismiss_help_if_open(self) -> None: 

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

717 

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

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

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

721 is mounted. 

722 """ 

723 from textual.actions import SkipAction 

724 

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

726 self.action_hide_help_panel() 

727 return 

728 raise SkipAction() 

729 

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

731 """Hide the letter view-keys from the footer while a text input is focused. 

732 

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

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

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

736 """ 

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

738 # screen/app bindings see them (verified empirically: this holds for 

739 # priority bindings too), so `t`/`m` type literals there and the guard 

740 # exists purely to keep the footer honest. 

741 if action in ("open_tasks", "open_catalog", "open_chat") and isinstance( 

742 self.focused, (Input, TextArea) 

743 ): 

744 return False 

745 # Nothing to jump to when Chat is already the active view. 

746 if action == "open_chat" and self.active_view == msg.DEFAULT_VIEW: 

747 return False 

748 # False, not None: Textual drops a False binding from the row entirely 

749 # and renders a None one greyed but present. With sessions off there is 

750 # nothing to toggle, so the key should not take a footer cell at all. 

751 if action == "toggle_sessions" and not cfg.sessions_enabled: 

752 return False 

753 # Drop each drawer toggle only where pressing it would do nothing, which 

754 # is the view that already shows the same panel full-screen. An OPEN 

755 # DRAWER is not that case: the key closes it, and the drawer contains the 

756 # very widget these predicates look for, so testing the panel alone 

757 # disabled the key that closes the drawer. 

758 if action == "toggle_fleet" and self._toggle_fleet_is_noop(): 

759 return False 

760 if action == "toggle_sessions" and self._toggle_sessions_is_noop(): 

761 return False 

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

763 

764 def go_back(self) -> None: 

765 """Return to the view the user came from (Chat when there is none).""" 

766 self.switch_view(self._previous_view or msg.DEFAULT_VIEW) 

767 

768 def action_open_tasks(self) -> None: 

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

770 self.switch_view("Tasks") 

771 

772 def action_open_catalog(self) -> None: 

773 """Jump to the model catalog (m key).""" 

774 self.switch_view(msg.CATALOG_VIEW) 

775 

776 def action_open_chat(self) -> None: 

777 """Jump to Chat (c key), the counterpart to t / m for the busiest view.""" 

778 self.switch_view(msg.DEFAULT_VIEW) 

779 

780 def _shows_placement_full_screen(self) -> bool: 

781 """True when this screen shows the placement editor, tab or drawer. 

782 

783 FleetDrawer composes a FleetBody, so this is also True while the drawer 

784 is open. Callers that care about "nothing left to do" must rule the 

785 drawer out first, as :meth:`_toggle_fleet_is_noop` does. 

786 """ 

787 return bool(self.screen.query("FleetBody")) 

788 

789 def _shows_sessions_full_screen(self) -> bool: 

790 """True when this screen shows the session list, tab or drawer. 

791 

792 SessionsDrawer composes a SessionListPanel, so the same caveat as 

793 :meth:`_shows_placement_full_screen` applies. 

794 """ 

795 return bool(self.screen.query("SessionListPanel")) 

796 

797 def _toggle_fleet_is_noop(self) -> bool: 

798 """True when ctrl+g would do nothing, mirroring the action's own order. 

799 

800 The action closes an open drawer first and only then treats a 

801 full-screen placement editor as a reason to stop, so a drawer that can 

802 be closed is never a no-op. 

803 """ 

804 from lilbee.cli.tui.widgets.fleet_drawer import FleetDrawer 

805 

806 if self.screen.query(FleetDrawer): 

807 return False 

808 return self._shows_placement_full_screen() 

809 

810 def _toggle_sessions_is_noop(self) -> bool: 

811 """True when ctrl+o would do nothing. See :meth:`_toggle_fleet_is_noop`.""" 

812 from lilbee.cli.tui.widgets.sessions_drawer import SessionsDrawer 

813 

814 if self.screen.query(SessionsDrawer): 

815 return False 

816 return self._shows_sessions_full_screen() 

817 

818 def action_toggle_fleet(self) -> None: 

819 """Toggle the Fleet drawer (ctrl+g): dock placement beside the current 

820 screen, or close it if already open. No-op on the Fleet tab, which 

821 already shows the full placement editor.""" 

822 from lilbee.cli.tui.widgets.fleet_drawer import FleetDrawer 

823 

824 drawers = self.screen.query(FleetDrawer) 

825 if drawers: 

826 drawers.first().remove() 

827 return 

828 if self._shows_placement_full_screen(): 

829 return 

830 self.screen.mount(FleetDrawer()) 

831 

832 def _notify_sessions_disabled(self) -> None: 

833 """Show the modal explaining sessions are off. Every session entry point 

834 (ctrl+o, the Sessions tab, /sessions) routes here when disabled.""" 

835 from lilbee.cli.tui.widgets.notice_dialog import NoticeDialog 

836 

837 # Guard against stacking a second copy if the entry point is hit twice. 

838 if isinstance(self.screen, NoticeDialog): 

839 return 

840 self.push_screen(NoticeDialog(msg.SESSIONS_DISABLED_TITLE, msg.SESSIONS_DISABLED_MESSAGE)) 

841 

842 def action_toggle_sessions(self) -> None: 

843 """Toggle the Sessions drawer (ctrl+o), or close it if open. No-op on the 

844 Sessions tab, which already shows the full list. Shows a notice when 

845 sessions are turned off.""" 

846 if not cfg.sessions_enabled: 

847 self._notify_sessions_disabled() 

848 return 

849 from lilbee.cli.tui.widgets.sessions_drawer import SessionsDrawer 

850 

851 drawers = self.screen.query(SessionsDrawer) 

852 if drawers: 

853 drawers.first().remove() 

854 return 

855 if self._shows_sessions_full_screen(): 

856 return 

857 self.screen.mount(SessionsDrawer()) 

858 

859 def resume_session(self, session_id: str) -> None: 

860 """Load a saved session into chat and switch to the chat view.""" 

861 chat = self.chat_screen() 

862 if chat is None: 

863 return 

864 chat.resume_session(session_id) 

865 self.switch_view(msg.DEFAULT_VIEW) 

866 

867 def new_chat(self) -> None: 

868 """Start a fresh conversation and switch to the chat view.""" 

869 chat = self.chat_screen() 

870 if chat is None: 

871 return 

872 chat.start_new_conversation() 

873 self.switch_view(msg.DEFAULT_VIEW) 

874 

875 def current_session_id(self) -> str | None: 

876 """The id of the conversation the chat screen is currently persisting to.""" 

877 chat = self.chat_screen() 

878 return chat.session_id if chat is not None else None 

879 

880 def action_global_slash_to_chat(self) -> None: 

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

882 

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

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

885 binding before the slash command can compose. 

886 """ 

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

888 

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

890 self.switch_view(msg.DEFAULT_VIEW) 

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

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

893 # and ready when we prefill it. 

894 self.call_later(self._prefill_chat_command) 

895 

896 def _prefill_chat_command(self) -> None: 

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

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

899 

900 if isinstance(self.screen, ChatScreen): 

901 self.screen.action_focus_commands() 

902 

903 def chat_screen(self) -> ChatScreen | None: 

904 """The installed chat screen, or None before the startup gate installs it.""" 

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

906 

907 try: 

908 return cast("ChatScreen", self.get_screen(_CHAT_SCREEN_NAME, ChatScreen)) 

909 except KeyError: 

910 return None 

911 

912 def action_run_sync(self) -> None: 

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

914 

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

916 everywhere. Routes to the registered ChatScreen which owns the 

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

918 not already there so the user can watch progress. 

919 """ 

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

921 

922 if isinstance(self.screen, ChatScreen): 

923 self.screen._run_sync() 

924 return 

925 chat = self.chat_screen() 

926 if chat is None: 

927 return 

928 

929 # switch_view drops the request outright while its re-entrancy guard is 

930 # held (an earlier switch still in flight), so the retry must re-attempt 

931 # the switch itself, not just wait for one that may never have started. 

932 def _start(attempts: int = 600) -> None: 

933 if not self.screen_stack: 

934 return # the app is tearing down; nothing left to sync 

935 if isinstance(self.screen, ChatScreen): 

936 chat._run_sync() 

937 return 

938 if attempts > 0: 

939 self.switch_view(msg.DEFAULT_VIEW) 

940 self.set_timer(0.05, lambda: _start(attempts - 1)) 

941 

942 self.call_later(_start) 

943 

944 def action_nav_prev(self) -> None: 

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

946 view_names = msg.get_nav_views() 

947 current_idx = view_names.index(self.active_view) 

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

949 

950 def action_nav_next(self) -> None: 

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

952 view_names = msg.get_nav_views() 

953 current_idx = view_names.index(self.active_view) 

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

955 

956 

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

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

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

960 

961 

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

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

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