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

444 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-28 17:20 +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.notifications import SeverityLevel 

22from textual.reactive import reactive 

23from textual.screen import Screen 

24from textual.signal import Signal 

25from textual.widgets import Input, TextArea 

26 

27from lilbee.app.services import get_services, peek_services 

28from lilbee.app.settings import SettingsUpdateResult, apply_settings_update 

29from lilbee.app.setup_state import chat_ready, embedding_ready 

30from lilbee.app.themes import DARK_THEMES 

31from lilbee.cli.tui import messages as msg 

32from lilbee.cli.tui.color_compat import ( 

33 EightBitPalette, 

34 draws_block_bars, 

35 draws_block_glyphs, 

36 needs_eight_bit, 

37 resolve_term_program, 

38) 

39from lilbee.cli.tui.commands import LilbeeCommandProvider 

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

41from lilbee.cli.tui.thread_safe import call_from_thread 

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

43from lilbee.config_meta import MODEL_ROLE_FIELDS 

44from lilbee.core.config import cfg 

45from lilbee.providers.roles import WorkerRole 

46 

47if TYPE_CHECKING: 

48 from lilbee.app.services import Services 

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

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

51 

52log = logging.getLogger(__name__) 

53 

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

55_CHAT_SCREEN_NAME = "chat" 

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

57_FALLBACK_TOAST_TIMEOUT_S = 10.0 

58 

59 

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

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

62 return view_name.lower() 

63 

64 

65def _make_catalog() -> Screen: 

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

67 

68 return CatalogScreen() 

69 

70 

71def _make_status() -> Screen: 

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

73 

74 return StatusScreen() 

75 

76 

77def _make_settings() -> Screen: 

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

79 

80 return SettingsScreen() 

81 

82 

83def _make_tasks() -> Screen: 

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

85 

86 return TaskCenter() 

87 

88 

89def _make_wiki() -> Screen: 

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

91 

92 return WikiScreen() 

93 

94 

95def _make_fleet() -> Screen: 

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

97 

98 return FleetScreen() 

99 

100 

101def _make_sessions() -> Screen: 

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

103 

104 return SessionsScreen() 

105 

106 

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

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

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

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

111 msg.CATALOG_VIEW: _make_catalog, 

112 "Status": _make_status, 

113 "Settings": _make_settings, 

114 "Tasks": _make_tasks, 

115 "Wiki": _make_wiki, 

116 "Fleet": _make_fleet, 

117 "Sessions": _make_sessions, 

118} 

119 

120 

121def _import_chat_stack() -> None: 

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

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

124 

125 

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

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

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

129 

130 

131class LilbeeApp(App[None]): 

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

133 

134 TITLE = "lilbee" 

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

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

137 # where the terminal needs it; see __init__. 

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

139 ENABLE_COMMAND_PALETTE = True 

140 COMMANDS = {LilbeeCommandProvider} # noqa: RUF012 

141 

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

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

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

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

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

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

148 # members stay adjacent. 

149 _NAV_GROUP = Binding.Group("Views") 

150 

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

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

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

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

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

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

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

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

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

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

161 # types the literal letter instead. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

176 Binding( 

177 "left_square_bracket", 

178 "nav_prev", 

179 "Prev", 

180 show=True, 

181 group=_NAV_GROUP, 

182 priority=True, 

183 ), 

184 Binding( 

185 "right_square_bracket", 

186 "nav_next", 

187 "Next", 

188 show=True, 

189 group=_NAV_GROUP, 

190 priority=True, 

191 ), 

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

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

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

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

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

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

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

199 # the key stays discoverable. 

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

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

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

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

204 # to Chat with the slash already typed. 

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

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

207 ] 

208 

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

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

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

212 # screens can watch them instead of polling. 

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

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

215 

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

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

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

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

220 # complete when Textual reads it. 

221 color_system = Console().color_system 

222 term_program = resolve_term_program(os.environ) 

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

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

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

226 draws_block_bars() 

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

228 # restating Textual's own block borders. 

229 self._eight_bit_filter = ( 

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

231 ) 

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

233 if self._plain_glyphs: 

234 css.append(self.SAFE_CSS_PATH) 

235 super().__init__(css_path=css) 

236 self._initial_view = initial_view 

237 self.active_view = msg.DEFAULT_VIEW 

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

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

240 self._previous_view: str | None = None 

241 self._switching = False 

242 self._theme_index = 0 

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

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

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

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

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

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

249 self, "provider_availability_changed" 

250 ) 

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

252 

253 self.task_bar = TaskBarController(self) 

254 

255 def notify( 

256 self, 

257 message: str, 

258 *, 

259 title: str = "", 

260 severity: SeverityLevel = "information", 

261 timeout: float | None = None, 

262 markup: bool = False, 

263 ) -> None: 

264 """Show a toast whose message is always literal text, never markup.""" 

265 super().notify(message, title=title, severity=severity, timeout=timeout, markup=False) 

266 

267 def compose(self) -> ComposeResult: 

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

269 

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

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

272 

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

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

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

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

277 that needs it falls back to box-drawing. 

278 """ 

279 variables = super().get_css_variables() 

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

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

282 return variables 

283 

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

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

286 

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

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

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

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

291 

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

293 made in __init__. 

294 """ 

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

296 if self._eight_bit_filter is not None: 

297 filters.append(self._eight_bit_filter) 

298 return filters 

299 

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

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

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

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

304 _test_skip_auto_init: ClassVar[bool] = False 

305 

306 async def on_mount(self) -> None: 

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

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

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

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

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

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

313 if self._test_skip_auto_init: 

314 return 

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

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

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

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

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

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

321 

322 gate = StartupGate() 

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

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

325 await self.push_screen(gate) 

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

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

328 # picked last session, not always the default. 

329 persisted = cfg.theme or _DEFAULT_THEME 

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

331 self._sync_theme_index_to_current() 

332 

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

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

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

336 self.call_after_refresh(self._load_chat_screen, gate) 

337 

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

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

340 

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

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

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

344 """ 

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

346 self._install_chat_screen(gate) 

347 return 

348 self._chat_import_worker(gate) 

349 

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

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

352 try: 

353 _import_chat_stack() 

354 except Exception as exc: 

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

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

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

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

359 return 

360 call_from_thread(self, self._install_chat_screen, gate) 

361 

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

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

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

365 

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

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

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

369 

370 chat = ChatScreen() 

371 self.install_screen(chat, name=_CHAT_SCREEN_NAME) 

372 gate.start_boot() 

373 

374 def reveal_landing(self) -> None: 

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

376 

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

378 Catalog, where models are installed. 

379 """ 

380 if self.chat_is_ready: 

381 self.switch_screen(_CHAT_SCREEN_NAME) 

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

383 self.switch_view(self._initial_view) 

384 else: 

385 self.switch_view(msg.CATALOG_VIEW) 

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

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

388 self.task_bar.start_detect_pending() 

389 

390 def settle_landing(self) -> None: 

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

392 

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

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

395 """ 

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

397 

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

399 def refresh_readiness(self) -> None: 

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

401 chat = chat_ready() 

402 embedding = embedding_ready() 

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

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

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

406 self.adopt_services() 

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

408 

409 def adopt_services(self) -> None: 

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

411 

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

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

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

415 """ 

416 self._wire_worker_pool_notifications(get_services()) 

417 

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

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

420 self.chat_is_ready = chat 

421 self.embedding_is_ready = embedding 

422 

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

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

425 

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

427 so one subscription covers every surface that assigns one. 

428 """ 

429 key, _value = payload 

430 if key in MODEL_ROLE_FIELDS: 

431 self.refresh_readiness() 

432 

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

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

435 

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

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

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

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

440 for user content, not implementation detail. 

441 

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

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

444 """ 

445 

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

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

448 

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

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

451 

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

453 

454 def canonicalize_persisted_models(self) -> None: 

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

456 

457 Canonicalization reads model files and can probe local model servers 

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

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

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

461 updates marshal back to the main thread. 

462 """ 

463 from lilbee.modelhub.model_manager import ( 

464 ValidationResult, 

465 canonicalize_chat_model, 

466 canonicalize_embedding_model, 

467 ) 

468 

469 chat_canon = canonicalize_chat_model() 

470 embedding_canon = canonicalize_embedding_model() 

471 for canon, field, label in ( 

472 (chat_canon, "chat_model", "Chat"), 

473 (embedding_canon, "embedding_model", "Embedding"), 

474 ): 

475 if canon.status == ValidationResult.OK: 

476 continue 

477 reason = canon.reason or msg.MODEL_REASON_DEFAULT 

478 

479 if canon.original == canon.effective: 

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

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

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

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

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

485 # chose. 

486 if canon.original: 

487 log.warning( 

488 msg.MODEL_UNUSABLE_NO_FALLBACK.format( 

489 label=label, original=canon.original, reason=reason 

490 ) 

491 ) 

492 continue 

493 

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

495 try: 

496 apply_settings_update({field: canon.effective}) 

497 except (ValueError, OSError): 

498 log.warning( 

499 msg.MODEL_FALLBACK_FAILED.format( 

500 label=label, 

501 original=canon.original, 

502 effective=canon.effective, 

503 reason=reason, 

504 ), 

505 exc_info=True, 

506 ) 

507 continue 

508 if not canon.original: 

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

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

511 # fallback worth a warning toast. 

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

513 continue 

514 notice = msg.MODEL_FALLBACK_NOTICE.format( 

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

516 ) 

517 log.warning(notice) 

518 call_from_thread( 

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

520 ) 

521 call_from_thread(self, self._refresh_title) 

522 

523 def _refresh_title(self) -> None: 

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

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

526 

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

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

529 from lilbee.core.config.keys import PROVIDER_API_KEYS 

530 

531 key, value = payload 

532 if key in PROVIDER_API_KEYS: 

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

534 

535 def action_cycle_theme(self) -> None: 

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

537 name = DARK_THEMES[self._theme_index] 

538 self._apply_and_persist_theme(name) 

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

540 

541 def action_toggle_lilbee_path(self) -> None: 

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

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

544 

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

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

547 if name in self.available_themes: 

548 self._apply_and_persist_theme(name) 

549 self._sync_theme_index_to_current() 

550 

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

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

553 

554 self.theme = name 

555 apply_settings_update({"theme": name}) 

556 

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

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

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

560 if not isinstance(value, str): 

561 return False 

562 downloading = self.task_bar.downloading_label_for(value) 

563 if downloading is None: 

564 return False 

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

566 return True 

567 

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

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

570 

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

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

573 """ 

574 if self._reject_if_downloading(value): 

575 return 

576 try: 

577 result = apply_settings_update({key: value}) 

578 except ValueError as exc: 

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

580 return 

581 self._notify_update_warnings(result) 

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

583 

584 def _notify_update_warnings(self, result: SettingsUpdateResult) -> None: 

585 """Toast each warning a settings update reports.""" 

586 for warning in result.warnings: 

587 self.notify(warning, severity="warning") 

588 

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

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

591 

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

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

594 propagate. 

595 """ 

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

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

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

599 return 

600 self._notify_update_warnings(apply_settings_update({key: value})) 

601 normalized = getattr(cfg, key) 

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

603 self.theme = normalized 

604 self._sync_theme_index_to_current() 

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

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

607 self._offer_wiki_wipe() 

608 

609 def _offer_wiki_wipe(self) -> None: 

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

611 

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

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

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

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

616 """ 

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

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

619 

620 confirm_wiki_wipe( 

621 self, 

622 title=WIKI_WIPE_DISABLED_TITLE, 

623 message=WIKI_WIPE_DISABLED_MESSAGE, 

624 notify_when_empty=False, 

625 ) 

626 

627 def _sync_theme_index_to_current(self) -> None: 

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

629 try: 

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

631 except ValueError: 

632 self._theme_index = 0 

633 

634 async def action_quit(self) -> None: 

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

636 

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

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

639 engine warm or a sync never swallows a quit. 

640 """ 

641 get_services().cancel_inference() 

642 

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

644 

645 screen = self.screen 

646 if isinstance(screen, ChatScreen) and screen.streaming and not screen.stopping: 

647 screen.action_cancel_stream() 

648 self.notify(msg.APP_QUIT_AGAIN_HINT) 

649 return 

650 self.exit() 

651 

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

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

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

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

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

657 self.notify_sessions_disabled() 

658 return True 

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

660 

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

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

663 

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

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

666 after the switch completes. 

667 """ 

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

669 return 

670 self._switching = True 

671 if view_name != self.active_view: 

672 self._previous_view = self.active_view 

673 

674 awaitable: AwaitComplete | None = None 

675 if view_name == msg.DEFAULT_VIEW: 

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

677 

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

679 awaitable = self.switch_screen(_CHAT_SCREEN_NAME) 

680 # Already on Chat, just update state below. 

681 else: 

682 screen_name = _view_screen_name(view_name) 

683 if screen_name not in self._installed_screen_names: 

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

685 self._installed_screen_names.add(screen_name) 

686 awaitable = self.switch_screen(screen_name) 

687 

688 self.active_view = view_name 

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

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

691 with contextlib.suppress(NoMatches): 

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

693 

694 async def _release() -> None: 

695 # switch_screen updates the stack synchronously but finishes mounting 

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

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

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

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

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

701 # single completed switch still releases promptly. 

702 if awaitable is not None: 

703 await awaitable 

704 self._switching = False 

705 

706 self.call_next(_release) 

707 

708 def action_push_help(self) -> None: 

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

710 self.action_hide_help_panel() 

711 else: 

712 self.action_show_help_panel() 

713 

714 def action_command_palette(self) -> None: 

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

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

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

718 

719 screen = self.screen 

720 if isinstance(screen, ChatScreen): 

721 try: 

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

723 except NoMatches: 

724 overlay = None 

725 if overlay is not None and overlay.is_visible: 

726 screen.action_complete_prev() 

727 return 

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

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

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

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

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

733 

734 def action_dismiss_help_if_open(self) -> None: 

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

736 

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

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

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

740 is mounted. 

741 """ 

742 from textual.actions import SkipAction 

743 

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

745 self.action_hide_help_panel() 

746 return 

747 raise SkipAction() 

748 

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

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

751 

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

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

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

755 """ 

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

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

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

759 # exists purely to keep the footer honest. 

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

761 self.focused, (Input, TextArea) 

762 ): 

763 return False 

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

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

766 return False 

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

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

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

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

771 return False 

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

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

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

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

776 # disabled the key that closes the drawer. 

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

778 return False 

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

780 return False 

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

782 

783 def go_back(self) -> None: 

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

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

786 

787 def action_open_tasks(self) -> None: 

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

789 self.switch_view("Tasks") 

790 

791 def action_open_catalog(self) -> None: 

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

793 self.switch_view(msg.CATALOG_VIEW) 

794 

795 def action_open_chat(self) -> None: 

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

797 self.switch_view(msg.DEFAULT_VIEW) 

798 

799 def _shows_placement_full_screen(self) -> bool: 

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

801 

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

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

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

805 """ 

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

807 

808 def _shows_sessions_full_screen(self) -> bool: 

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

810 

811 SessionsDrawer composes a SessionListPanel, so the same caveat as 

812 :meth:`_shows_placement_full_screen` applies. 

813 """ 

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

815 

816 def _toggle_fleet_is_noop(self) -> bool: 

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

818 

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

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

821 be closed is never a no-op. 

822 """ 

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

824 

825 if self.screen.query(FleetDrawer): 

826 return False 

827 return self._shows_placement_full_screen() 

828 

829 def _toggle_sessions_is_noop(self) -> bool: 

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

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

832 

833 if self.screen.query(SessionsDrawer): 

834 return False 

835 return self._shows_sessions_full_screen() 

836 

837 def action_toggle_fleet(self) -> None: 

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

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

840 already shows the full placement editor.""" 

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

842 

843 drawers = self.screen.query(FleetDrawer) 

844 if drawers: 

845 drawers.first().remove() 

846 return 

847 if self._shows_placement_full_screen(): 

848 return 

849 self.screen.mount(FleetDrawer()) 

850 

851 def notify_sessions_disabled(self) -> None: 

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

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

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

855 

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

857 if isinstance(self.screen, NoticeDialog): 

858 return 

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

860 

861 async def action_toggle_sessions(self) -> None: 

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

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

864 sessions are turned off. Awaits the mount, so the drawer holds focus 

865 before the next key is routed.""" 

866 if not cfg.sessions_enabled: 

867 self.notify_sessions_disabled() 

868 return 

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

870 

871 drawers = self.screen.query(SessionsDrawer) 

872 if drawers: 

873 drawers.first().remove() 

874 return 

875 if self._shows_sessions_full_screen(): 

876 return 

877 await self.screen.mount(SessionsDrawer()) 

878 

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

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

881 chat = self.chat_screen() 

882 if chat is None: 

883 return 

884 chat.resume_session(session_id) 

885 self.switch_view(msg.DEFAULT_VIEW) 

886 

887 def new_chat(self) -> None: 

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

889 chat = self.chat_screen() 

890 if chat is None: 

891 return 

892 chat.start_new_conversation() 

893 self.switch_view(msg.DEFAULT_VIEW) 

894 

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

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

897 chat = self.chat_screen() 

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

899 

900 def action_global_slash_to_chat(self) -> None: 

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

902 

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

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

905 binding before the slash command can compose. 

906 """ 

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

908 

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

910 self.switch_view(msg.DEFAULT_VIEW) 

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

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

913 # and ready when we prefill it. 

914 self.call_later(self._prefill_chat_command) 

915 

916 def _prefill_chat_command(self) -> None: 

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

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

919 

920 if isinstance(self.screen, ChatScreen): 

921 self.screen.action_focus_commands() 

922 

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

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

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

926 

927 try: 

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

929 except KeyError: 

930 return None 

931 

932 def action_run_sync(self) -> None: 

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

934 

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

936 everywhere. Routes to the registered ChatScreen which owns the 

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

938 not already there so the user can watch progress. 

939 """ 

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

941 

942 if isinstance(self.screen, ChatScreen): 

943 self.screen._run_sync() 

944 return 

945 chat = self.chat_screen() 

946 if chat is None: 

947 return 

948 

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

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

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

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

953 if not self.screen_stack: 

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

955 if isinstance(self.screen, ChatScreen): 

956 chat._run_sync() 

957 return 

958 if attempts > 0: 

959 self.switch_view(msg.DEFAULT_VIEW) 

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

961 

962 self.call_later(_start) 

963 

964 def action_nav_prev(self) -> None: 

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

966 view_names = msg.get_nav_views() 

967 current_idx = view_names.index(self.active_view) 

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

969 

970 def action_nav_next(self) -> None: 

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

972 view_names = msg.get_nav_views() 

973 current_idx = view_names.index(self.active_view) 

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

975 

976 

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

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

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

980 

981 

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

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

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