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

559 statements  

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

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

2 

3from __future__ import annotations 

4 

5import contextlib 

6import logging 

7import os 

8import sys 

9import threading 

10from collections.abc import Callable, Iterable, Sequence 

11from pathlib import Path, PurePath 

12from typing import TYPE_CHECKING, Any, ClassVar, cast 

13 

14from rich.console import Console 

15from textual import work 

16from textual.app import App, ComposeResult 

17from textual.await_complete import AwaitComplete 

18from textual.binding import Binding, BindingType 

19from textual.command import CommandPalette 

20from textual.css.query import NoMatches 

21from textual.filter import LineFilter 

22from textual.notifications import SeverityLevel 

23from textual.reactive import reactive 

24from textual.screen import Screen 

25from textual.signal import Signal 

26from textual.widgets import Input, TextArea 

27 

28from lilbee.app.services import get_services, peek_services, set_config_refresh 

29from lilbee.app.settings import ( 

30 SettingsUpdateResult, 

31 adopt_external_changes, 

32 apply_settings_update, 

33 config_write_failure_message, 

34 reset_for_provider_switch, 

35 reset_settings, 

36 setting_sources, 

37 start_config_reading, 

38) 

39from lilbee.app.setup_state import chat_ready, embedding_ready 

40from lilbee.app.themes import DARK_THEMES 

41from lilbee.cli.app import chat_model_overridden 

42from lilbee.cli.tui import messages as msg 

43from lilbee.cli.tui.color_compat import ( 

44 EightBitPalette, 

45 draws_block_bars, 

46 draws_block_glyphs, 

47 needs_eight_bit, 

48 resolve_term_program, 

49) 

50from lilbee.cli.tui.commands import LilbeeCommandProvider 

51from lilbee.cli.tui.screens.analyze_report import start_analysis 

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

53from lilbee.cli.tui.thread_safe import call_from_thread 

54from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog 

55from lilbee.cli.tui.widgets.drawer import first_direct_child 

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

57from lilbee.config_meta import MODEL_ROLE_FIELDS 

58from lilbee.core.config import cfg, load_warnings 

59from lilbee.core.config.defaults import env_var_name 

60from lilbee.core.config.enums import SettingSource 

61from lilbee.providers.roles import WorkerRole 

62 

63if TYPE_CHECKING: 

64 from lilbee.app.services import Services 

65 from lilbee.cli.tui.screens.analyze_report import FinishedAnalysis 

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

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

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

69 

70log = logging.getLogger(__name__) 

71 

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

73_CHAT_SCREEN_NAME = "chat" 

74# Textual's default screen, then the current view; anything higher is pushed over the view. 

75_VIEW_DEPTH = 2 

76# Long enough that a model-fallback notice or a load warning is readable before it fades. 

77_FALLBACK_TOAST_TIMEOUT_S = 10.0 

78 

79 

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

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

82 return view_name.lower() 

83 

84 

85def _make_catalog() -> Screen: 

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

87 

88 return CatalogScreen() 

89 

90 

91def _make_status() -> Screen: 

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

93 

94 return StatusScreen() 

95 

96 

97def _make_settings() -> Screen: 

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

99 

100 return SettingsScreen() 

101 

102 

103def _make_tasks() -> Screen: 

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

105 

106 return TaskCenter() 

107 

108 

109def _make_wiki() -> Screen: 

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

111 

112 return WikiScreen() 

113 

114 

115def _make_fleet() -> Screen: 

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

117 

118 return FleetScreen() 

119 

120 

121def _make_sessions() -> Screen: 

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

123 

124 return SessionsScreen() 

125 

126 

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

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

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

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

131 msg.CATALOG_VIEW: _make_catalog, 

132 "Status": _make_status, 

133 "Settings": _make_settings, 

134 "Tasks": _make_tasks, 

135 "Wiki": _make_wiki, 

136 "Fleet": _make_fleet, 

137 "Sessions": _make_sessions, 

138} 

139 

140 

141def _import_chat_stack() -> None: 

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

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

144 

145 

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

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

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

149 

150 

151class LilbeeApp(App[None]): 

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

153 

154 TITLE = "lilbee" 

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

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

157 # where the terminal needs it; see __init__. 

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

159 ENABLE_COMMAND_PALETTE = True 

160 COMMANDS = {LilbeeCommandProvider} # noqa: RUF012 

161 

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

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

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

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

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

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

168 # members stay adjacent. 

169 _NAV_GROUP = Binding.Group("Views") 

170 

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

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

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

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

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

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

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

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

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

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

181 # types the literal letter instead. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

196 Binding( 

197 "left_square_bracket", 

198 "nav_prev", 

199 "Prev", 

200 show=True, 

201 group=_NAV_GROUP, 

202 priority=True, 

203 ), 

204 Binding( 

205 "right_square_bracket", 

206 "nav_next", 

207 "Next", 

208 show=True, 

209 group=_NAV_GROUP, 

210 priority=True, 

211 ), 

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

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

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

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

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

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

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

219 # the key stays discoverable. 

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

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

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

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

224 # to Chat with the slash already typed. 

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

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

227 ] 

228 

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

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

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

232 # screens can watch them instead of polling. 

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

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

235 

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

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

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

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

240 # complete when Textual reads it. 

241 color_system = Console().color_system 

242 term_program = resolve_term_program(os.environ) 

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

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

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

246 draws_block_bars() 

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

248 # restating Textual's own block borders. 

249 self._eight_bit_filter = ( 

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

251 ) 

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

253 if self._plain_glyphs: 

254 css.append(self.SAFE_CSS_PATH) 

255 super().__init__(css_path=css) 

256 self._initial_view = initial_view 

257 self.active_view = msg.DEFAULT_VIEW 

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

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

260 self._previous_view: str | None = None 

261 self._switching = False 

262 self._theme_index = 0 

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

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

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

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

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

268 self._config_reading = start_config_reading() 

269 # The app thread and the engine's retry thread both take config.toml changes. 

270 self._config_lock = threading.Lock() 

271 set_config_refresh(self.take_external_settings_now) 

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

273 self, "provider_availability_changed" 

274 ) 

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

276 

277 self.task_bar = TaskBarController(self) 

278 self.last_analysis: FinishedAnalysis | None = None 

279 

280 def notify( 

281 self, 

282 message: str, 

283 *, 

284 title: str = "", 

285 severity: SeverityLevel = "information", 

286 timeout: float | None = None, 

287 markup: bool = False, 

288 ) -> None: 

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

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

291 

292 def compose(self) -> ComposeResult: 

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

294 

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

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

297 

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

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

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

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

302 that needs it falls back to box-drawing. 

303 """ 

304 variables = super().get_css_variables() 

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

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

307 return variables 

308 

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

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

311 

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

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

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

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

316 

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

318 made in __init__. 

319 """ 

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

321 if self._eight_bit_filter is not None: 

322 filters.append(self._eight_bit_filter) 

323 return filters 

324 

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

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

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

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

329 _test_skip_auto_init: ClassVar[bool] = False 

330 

331 # Keys pressed while the startup gate showed; the chat prompt takes them on show. 

332 type_ahead: str = "" 

333 

334 async def on_mount(self) -> None: 

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

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

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

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

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

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

341 if self._test_skip_auto_init: 

342 return 

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

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

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

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

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

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

349 

350 gate = StartupGate() 

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

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

353 await self.push_screen(gate) 

354 for warning in load_warnings: 

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

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

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

358 # picked last session, not always the default. 

359 persisted = cfg.theme or _DEFAULT_THEME 

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

361 self._sync_theme_index_to_current() 

362 

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

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

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

366 self.call_after_refresh(self._load_chat_screen, gate) 

367 

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

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

370 

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

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

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

374 """ 

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

376 self._install_chat_screen(gate) 

377 return 

378 self._chat_import_worker(gate) 

379 

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

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

382 try: 

383 _import_chat_stack() 

384 except Exception as exc: 

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

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

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

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

389 return 

390 call_from_thread(self, self._install_chat_screen, gate) 

391 

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

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

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

395 

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

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

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

399 

400 chat = ChatScreen() 

401 self.install_screen(chat, name=_CHAT_SCREEN_NAME) 

402 gate.start_boot() 

403 

404 def reveal_landing(self) -> None: 

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

406 

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

408 Catalog, where models are installed. 

409 """ 

410 if self.chat_is_ready: 

411 self.switch_screen(_CHAT_SCREEN_NAME) 

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

413 self.switch_view(self._initial_view) 

414 else: 

415 self.type_ahead = "" 

416 self.switch_view(msg.CATALOG_VIEW) 

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

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

419 self.task_bar.start_detect_pending() 

420 

421 def settle_landing(self) -> None: 

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

423 

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

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

426 """ 

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

428 

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

430 def refresh_readiness(self) -> None: 

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

432 chat = chat_ready() 

433 embedding = embedding_ready() 

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

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

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

437 self.adopt_services() 

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

439 

440 def adopt_services(self) -> None: 

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

442 

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

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

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

446 """ 

447 self._wire_worker_pool_notifications(get_services()) 

448 

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

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

451 self.chat_is_ready = chat 

452 self.embedding_is_ready = embedding 

453 

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

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

456 

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

458 so one subscription covers every surface that assigns one. 

459 """ 

460 key, _value = payload 

461 if key in MODEL_ROLE_FIELDS: 

462 self.refresh_readiness() 

463 

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

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

466 

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

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

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

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

471 for user content, not implementation detail. 

472 

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

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

475 """ 

476 

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

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

479 

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

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

482 

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

484 

485 def canonicalize_persisted_models(self) -> None: 

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

487 

488 Canonicalization reads model files and can probe local model servers 

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

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

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

492 updates marshal back to the main thread. 

493 """ 

494 from lilbee.modelhub.model_manager import ( 

495 ValidationResult, 

496 canonicalize_chat_model, 

497 canonicalize_embedding_model, 

498 ) 

499 

500 chat_canon = canonicalize_chat_model() 

501 embedding_canon = canonicalize_embedding_model() 

502 sources = setting_sources() 

503 for canon, field, label in ( 

504 (chat_canon, "chat_model", "Chat"), 

505 (embedding_canon, "embedding_model", "Embedding"), 

506 ): 

507 if canon.status == ValidationResult.OK: 

508 continue 

509 reason = canon.reason or msg.MODEL_REASON_DEFAULT 

510 

511 if canon.original == canon.effective: 

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

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

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

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

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

517 # chose. 

518 if canon.original: 

519 log.warning( 

520 msg.MODEL_UNUSABLE_NO_FALLBACK.format( 

521 label=label, original=canon.original, reason=reason 

522 ) 

523 ) 

524 continue 

525 

526 if field == "chat_model" and chat_model_overridden(): 

527 # --model outranks the env var and config.toml, so name the flag. 

528 self._warn_with_toast( 

529 msg.MODEL_CLI_PIN_UNUSABLE.format( 

530 label=label, original=canon.original, reason=reason 

531 ) 

532 ) 

533 continue 

534 

535 if sources[field] is SettingSource.ENV: 

536 # The env var outranks any swap, so name it instead of claiming one. 

537 self._warn_with_toast( 

538 msg.MODEL_ENV_PIN_UNUSABLE.format( 

539 label=label, 

540 original=canon.original, 

541 reason=reason, 

542 env_var=env_var_name(field), 

543 ) 

544 ) 

545 continue 

546 

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

548 try: 

549 apply_settings_update({field: canon.effective}) 

550 except (ValueError, OSError): 

551 log.warning( 

552 msg.MODEL_FALLBACK_FAILED.format( 

553 label=label, 

554 original=canon.original, 

555 effective=canon.effective, 

556 reason=reason, 

557 ), 

558 exc_info=True, 

559 ) 

560 continue 

561 if not canon.original: 

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

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

564 # fallback worth a warning toast. 

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

566 continue 

567 self._warn_with_toast( 

568 msg.MODEL_FALLBACK_NOTICE.format( 

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

570 ) 

571 ) 

572 call_from_thread(self, self._refresh_title) 

573 

574 def _warn_with_toast(self, notice: str) -> None: 

575 """Log *notice* at WARNING and toast it from the worker thread.""" 

576 log.warning(notice) 

577 call_from_thread( 

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

579 ) 

580 

581 def _refresh_title(self) -> None: 

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

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

584 

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

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

587 from lilbee.core.config.keys import PROVIDER_API_KEYS 

588 

589 key, value = payload 

590 if key in PROVIDER_API_KEYS: 

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

592 

593 def action_cycle_theme(self) -> None: 

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

595 name = DARK_THEMES[self._theme_index] 

596 if self._apply_and_persist_theme(name): 

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

598 

599 def action_toggle_lilbee_path(self) -> None: 

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

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

602 

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

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

605 if name in self.available_themes: 

606 self._apply_and_persist_theme(name) 

607 self._sync_theme_index_to_current() 

608 

609 def _apply_and_persist_theme(self, name: str) -> bool: 

610 """Apply *name* live and write it to config.toml. Returns whether the write worked. 

611 

612 The theme stays applied for this session when the write fails; the error toast says 

613 it was not saved. 

614 """ 

615 

616 self.theme = name 

617 try: 

618 apply_settings_update({"theme": name}) 

619 except OSError as exc: 

620 self.notify(config_write_failure_message(exc), severity="error") 

621 return False 

622 return True 

623 

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

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

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

627 if not isinstance(value, str): 

628 return False 

629 downloading = self.task_bar.downloading_label_for(value) 

630 if downloading is None: 

631 return False 

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

633 return True 

634 

635 def set_active_model(self, key: str, value: str) -> SettingsUpdateResult | None: 

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

637 

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

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

640 Returns the boundary's result, or None when the write is refused. 

641 """ 

642 if self._reject_if_downloading(value): 

643 return None 

644 try: 

645 result = apply_settings_update({key: value}) 

646 except (ValueError, OSError, RuntimeError) as exc: # RuntimeError: a store error 

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

648 return None 

649 self.notify_warnings(result.warnings) 

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

651 return result 

652 

653 def notify_warnings(self, warnings: Sequence[str]) -> None: 

654 """Toast each warning a settings update, reset or discard reports.""" 

655 for warning in warnings: 

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

657 

658 def set_setting(self, key: str, value: object, *, ask_rebuild: bool = True) -> bool: 

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

660 

661 Returns whether the value was saved. A failed config write (a read-only file, a full 

662 disk) toasts here, once, with the text the CLI, HTTP and MCP surfaces show, and 

663 returns False so the caller does not record the value as saved. With *ask_rebuild* 

664 False the caller asks about a rebuild itself; the wiki wipe question still comes. 

665 

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

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

668 propagate. 

669 """ 

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

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

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

673 return False 

674 try: 

675 result = apply_settings_update({key: value}) 

676 except OSError as exc: 

677 self.notify(config_write_failure_message(exc), severity="error") 

678 return False 

679 self.notify_warnings(result.warnings) 

680 self.publish_settings([key]) 

681 if ask_rebuild and result.reindex_required: 

682 self.offer_rebuild(msg.SETTINGS_REINDEX_MESSAGE, [key]) 

683 elif key == "wiki" and cfg.wiki is False: 

684 self._offer_wiki_wipe() 

685 return True 

686 

687 def reset_and_publish(self, keys: list[str], *, skip_unresettable: bool = False) -> list[str]: 

688 """Reset *keys* through the boundary, fan each out to the UI, and return the reset keys. 

689 

690 Raises ``ValueError`` or ``OSError`` from the boundary; nothing changes on either. 

691 A reset that turns the wiki off offers the wipe. It never offers a rebuild: the 

692 Settings screen asks once when the user leaves. 

693 """ 

694 wiki_was_on = cfg.wiki 

695 result = reset_settings(keys, skip_unresettable=skip_unresettable) 

696 self.notify_warnings(result.warnings) 

697 self.publish_settings(result.updated) 

698 if wiki_was_on and cfg.wiki is False: 

699 self._offer_wiki_wipe() 

700 return result.updated 

701 

702 def adopt_external_settings(self) -> bool: 

703 """Take into cfg what another process changed in config.toml, and tell subscribers. 

704 

705 Returns whether the file changed since the last check, so a caller can reload 

706 state that lives outside cfg, such as the profile name. An editor the user is 

707 typing in keeps its text; saving it writes that value over the other process's. 

708 """ 

709 changed, result = self._take_external_changes() 

710 if result is not None: 

711 self._show_taken_settings(result) 

712 return changed 

713 

714 def take_external_settings_now(self) -> None: 

715 """Take config.toml changes from the thread the engine retries on. 

716 

717 The settings land on cfg in the calling thread. The app thread shows them; 

718 on the app thread itself the take already happened and nothing is shown. 

719 """ 

720 try: 

721 _changed, result = self._take_external_changes() 

722 except Exception: 

723 log.exception("Could not take the changes in config.toml; the retry runs as before.") 

724 return 

725 if result is not None: 

726 call_from_thread(self, self._show_taken_settings, result) 

727 

728 def _take_external_changes(self) -> tuple[bool, SettingsUpdateResult | None]: 

729 """Take the outside changes under the lock every take shares; stop the fleet after it. 

730 

731 The reading moves with the values under the lock, so a take that waited finds the 

732 file taken. A provider switch stops the fleet, seconds long, once the lock is free. 

733 """ 

734 with self._config_lock: 

735 before = self._config_reading 

736 self._config_reading, result = adopt_external_changes(before, reset_providers=False) 

737 changed = self._config_reading is not before 

738 reset_for_provider_switch(result) 

739 return changed, result 

740 

741 def _show_taken_settings(self, result: SettingsUpdateResult) -> None: 

742 self.notify_warnings(result.warnings) 

743 self.publish_settings(result.updated) 

744 

745 def on_app_focus(self) -> None: 

746 """The terminal came back to the front: another shell may have changed config.toml.""" 

747 self.adopt_external_settings() 

748 

749 def publish_settings(self, keys: Iterable[str]) -> None: 

750 """Apply a changed theme and tell subscribers each of *keys* now holds its cfg value.""" 

751 for key in keys: 

752 normalized = getattr(cfg, key) 

753 if ( 

754 key == "theme" 

755 and isinstance(normalized, str) 

756 and normalized in self.available_themes 

757 ): 

758 self.theme = normalized 

759 self._sync_theme_index_to_current() 

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

761 

762 def _offer_wiki_wipe(self) -> None: 

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

764 

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

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

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

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

769 """ 

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

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

772 

773 confirm_wiki_wipe( 

774 self, 

775 title=WIKI_WIPE_DISABLED_TITLE, 

776 message=WIKI_WIPE_DISABLED_MESSAGE, 

777 notify_when_empty=False, 

778 ) 

779 

780 def _sync_theme_index_to_current(self) -> None: 

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

782 try: 

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

784 except ValueError: 

785 self._theme_index = 0 

786 

787 async def action_quit(self) -> None: 

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

789 

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

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

792 engine warm or a sync never swallows a quit. 

793 """ 

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

795 

796 screen = self.screen 

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

798 # The turn sets its stop flag first, then severs the stream. 

799 screen.action_cancel_stream() 

800 self.notify(msg.APP_QUIT_AGAIN_HINT) 

801 return 

802 get_services().cancel_inference() 

803 # A quit asks nothing: a rebuild the Settings edits need is named in the terminal. 

804 settings = self.settings_screen() 

805 self.exit(message=settings.leave() if settings is not None else None) 

806 

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

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

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

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

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

812 self.notify_sessions_disabled() 

813 return True 

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

815 

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

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

818 

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

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

821 after the switch completes. A screen pushed over the current view (the 

822 catalog a model field opens, a dialog) is popped first, so the switch 

823 replaces the view itself and no view stays on the stack under another. 

824 """ 

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

826 return 

827 self._switching = True 

828 if view_name != self.active_view: 

829 self._previous_view = self.active_view 

830 

831 pops = self._pop_to_view() 

832 awaitable: AwaitComplete | None = None 

833 if view_name == msg.DEFAULT_VIEW: 

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

835 

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

837 awaitable = self.switch_screen(_CHAT_SCREEN_NAME) 

838 # Already on Chat, just update state below. 

839 else: 

840 screen_name = _view_screen_name(view_name) 

841 if screen_name not in self._installed_screen_names: 

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

843 self._installed_screen_names.add(screen_name) 

844 awaitable = self.switch_screen(screen_name) 

845 

846 self.active_view = view_name 

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

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

849 with contextlib.suppress(NoMatches): 

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

851 

852 async def _release() -> None: 

853 # switch_screen updates the stack synchronously but finishes mounting 

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

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

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

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

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

859 # single completed switch still releases promptly. 

860 for pop in pops: 

861 await pop 

862 if awaitable is not None: 

863 await awaitable 

864 self._switching = False 

865 

866 self.call_next(_release) 

867 

868 def _pop_to_view(self) -> list[AwaitComplete]: 

869 """Pop each screen pushed over the current view; views are the installed screens. 

870 

871 The view sits right above Textual's default screen, so the walk stops there 

872 (the startup gate, not installed, holds that place until Chat replaces it). 

873 """ 

874 pops: list[AwaitComplete] = [] 

875 while len(self.screen_stack) > _VIEW_DEPTH and not self.is_screen_installed(self.screen): 

876 pops.append(self.pop_screen()) 

877 return pops 

878 

879 def action_push_help(self) -> None: 

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

881 self.action_hide_help_panel() 

882 else: 

883 self.action_show_help_panel() 

884 

885 def action_command_palette(self) -> None: 

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

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

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

889 

890 screen = self.screen 

891 if isinstance(screen, ChatScreen): 

892 try: 

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

894 except NoMatches: 

895 overlay = None 

896 if overlay is not None and overlay.is_visible: 

897 screen.action_complete_prev() 

898 return 

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

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

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

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

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

904 

905 def action_dismiss_help_if_open(self) -> None: 

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

907 

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

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

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

911 is mounted. 

912 """ 

913 from textual.actions import SkipAction 

914 

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

916 self.action_hide_help_panel() 

917 return 

918 raise SkipAction() 

919 

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

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

922 

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

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

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

926 """ 

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

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

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

930 # exists purely to keep the footer honest. 

931 # run_sync (S) is a priority binding. Live at the gate, it swaps chat in 

932 # under the gate, which then skips its handover. Off, the gate keeps the 

933 # key as typed text. 

934 if (action == "run_sync" and self._gate_is_showing()) or ( 

935 action in ("open_tasks", "open_catalog", "open_chat") 

936 and isinstance(self.focused, (Input, TextArea)) 

937 ): 

938 return False 

939 # Nothing to jump to when Chat is already the active view. [ and ] are 

940 # priority bindings, which run before a modal screen's own handling, so 

941 # over a dialog they would switch views and close it with no answer. 

942 if (action == "open_chat" and self.active_view == msg.DEFAULT_VIEW) or ( 

943 action in ("nav_prev", "nav_next") and self.screen.is_modal 

944 ): 

945 return False 

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

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

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

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

950 return False 

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

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

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

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

955 # disabled the key that closes the drawer. 

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

957 return False 

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

959 return False 

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

961 

962 def _gate_is_showing(self) -> bool: 

963 """True while the startup gate is the screen on top.""" 

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

965 

966 return isinstance(self.screen, StartupGate) 

967 

968 def go_back(self) -> None: 

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

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

971 

972 def action_open_tasks(self) -> None: 

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

974 self.switch_view("Tasks") 

975 

976 def action_open_catalog(self) -> None: 

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

978 self.switch_view(msg.CATALOG_VIEW) 

979 

980 def action_open_chat(self) -> None: 

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

982 self.switch_view(msg.DEFAULT_VIEW) 

983 

984 def _shows_placement_full_screen(self) -> bool: 

985 """True when the current screen hosts a FleetBody directly, not via the drawer. 

986 

987 FleetScreen composes its FleetBody as a direct child, the same shape 

988 FleetDrawer uses one level deeper; callers rule the drawer out first 

989 (:meth:`_toggle_fleet_is_noop`), so a direct-child FleetBody here is 

990 never the drawer's. 

991 """ 

992 from lilbee.cli.tui.widgets.fleet_body import FleetBody 

993 

994 return first_direct_child(self.screen, FleetBody) is not None 

995 

996 def _shows_sessions_full_screen(self) -> bool: 

997 """True when the current screen hosts a SessionListPanel directly, not via the drawer. 

998 

999 See :meth:`_shows_placement_full_screen`. 

1000 """ 

1001 from lilbee.cli.tui.widgets.session_list import SessionListPanel 

1002 

1003 return first_direct_child(self.screen, SessionListPanel) is not None 

1004 

1005 def _toggle_fleet_is_noop(self) -> bool: 

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

1007 

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

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

1010 be closed is never a no-op. 

1011 """ 

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

1013 

1014 if first_direct_child(self.screen, FleetDrawer) is not None: 

1015 return False 

1016 return self._shows_placement_full_screen() 

1017 

1018 def _toggle_sessions_is_noop(self) -> bool: 

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

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

1021 

1022 if first_direct_child(self.screen, SessionsDrawer) is not None: 

1023 return False 

1024 return self._shows_sessions_full_screen() 

1025 

1026 def action_toggle_fleet(self) -> None: 

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

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

1029 already shows the full placement editor.""" 

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

1031 

1032 drawer = first_direct_child(self.screen, FleetDrawer) 

1033 if drawer is not None: 

1034 drawer.remove() 

1035 return 

1036 if self._shows_placement_full_screen(): 

1037 return 

1038 self.screen.mount(FleetDrawer()) 

1039 

1040 def notify_sessions_disabled(self) -> None: 

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

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

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

1044 

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

1046 if isinstance(self.screen, NoticeDialog): 

1047 return 

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

1049 

1050 async def action_toggle_sessions(self) -> None: 

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

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

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

1054 before the next key is routed.""" 

1055 if not cfg.sessions_enabled: 

1056 self.notify_sessions_disabled() 

1057 return 

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

1059 

1060 drawer = first_direct_child(self.screen, SessionsDrawer) 

1061 if drawer is not None: 

1062 drawer.remove() 

1063 return 

1064 if self._shows_sessions_full_screen(): 

1065 return 

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

1067 

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

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

1070 chat = self.chat_screen() 

1071 if chat is None: 

1072 return 

1073 chat.resume_session(session_id) 

1074 self.switch_view(msg.DEFAULT_VIEW) 

1075 

1076 def new_chat(self) -> None: 

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

1078 chat = self.chat_screen() 

1079 if chat is None: 

1080 return 

1081 chat.start_new_conversation() 

1082 self.switch_view(msg.DEFAULT_VIEW) 

1083 

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

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

1086 chat = self.chat_screen() 

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

1088 

1089 def action_global_slash_to_chat(self) -> None: 

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

1091 

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

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

1094 binding before the slash command can compose. 

1095 """ 

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

1097 

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

1099 self.switch_view(msg.DEFAULT_VIEW) 

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

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

1102 # and ready when we prefill it. 

1103 self.call_later(self._prefill_chat_command) 

1104 

1105 def _prefill_chat_command(self) -> None: 

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

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

1108 

1109 if isinstance(self.screen, ChatScreen): 

1110 self.screen.action_focus_commands() 

1111 

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

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

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

1115 

1116 try: 

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

1118 except KeyError: 

1119 return None 

1120 

1121 def action_run_sync(self) -> None: 

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

1123 

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

1125 everywhere. Routes to the registered ChatScreen which owns the 

1126 ``run_sync`` orchestration; switches to the Chat view first if 

1127 not already there so the user can watch progress. 

1128 """ 

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

1130 

1131 if isinstance(self.screen, ChatScreen): 

1132 self.screen.run_sync() 

1133 return 

1134 chat = self.chat_screen() 

1135 if chat is None: 

1136 return 

1137 

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

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

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

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

1142 if not self.screen_stack: 

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

1144 if isinstance(self.screen, ChatScreen): 

1145 chat.run_sync() 

1146 return 

1147 if attempts > 0: 

1148 self.switch_view(msg.DEFAULT_VIEW) 

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

1150 

1151 self.call_later(_start) 

1152 

1153 def open_profile_tab(self) -> None: 

1154 """Switch to Settings with the Profile tab active and its dropdown focused.""" 

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

1156 

1157 self.switch_view("Settings") 

1158 screen = self.screen 

1159 # switch_view drops the request while an earlier switch is still settling 

1160 if isinstance(screen, SettingsScreen): 

1161 screen.call_after_refresh(screen.show_profile_tab) 

1162 

1163 def start_analyze(self) -> None: 

1164 """Queue analyze of the indexed documents; its report opens when it finishes.""" 

1165 start_analysis(self, None) 

1166 

1167 def start_rebuild(self) -> None: 

1168 """Queue a full reindex on the chat screen's task bar without leaving this view.""" 

1169 chat = self.chat_screen() 

1170 if chat is None: 

1171 return 

1172 chat.run_sync(force_rebuild=True) 

1173 # The rebuild builds the index with every value now in force. 

1174 self.rebuild_answered(None) 

1175 

1176 def rebuild_answered(self, keys: Iterable[str] | None) -> None: 

1177 """Count the values in force of *keys*, or of all when None, as asked about. 

1178 

1179 Leaving Settings then asks only about the other index values the visit changed. 

1180 """ 

1181 settings = self.settings_screen() 

1182 if settings is not None: 

1183 settings.take_index_values(keys) 

1184 

1185 def settings_screen(self) -> SettingsScreen | None: 

1186 """The Settings screen when it is on the screen stack, under any dialog.""" 

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

1188 

1189 return next((s for s in self.screen_stack if isinstance(s, SettingsScreen)), None) 

1190 

1191 def offer_rebuild(self, message: str, keys: Iterable[str] = ()) -> None: 

1192 """Ask whether to rebuild the index for *keys*, saying why in *message*; yes starts it.""" 

1193 

1194 def _answered(rebuild: bool | None) -> None: 

1195 if rebuild: 

1196 self.start_rebuild() 

1197 else: 

1198 self.rebuild_answered(keys) 

1199 

1200 self.push_screen(ConfirmDialog(msg.CMD_REBUILD_CONFIRM_TITLE, message), _answered) 

1201 

1202 def action_nav_prev(self) -> None: 

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

1204 view_names = msg.get_nav_views() 

1205 current_idx = view_names.index(self.active_view) 

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

1207 

1208 def action_nav_next(self) -> None: 

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

1210 view_names = msg.get_nav_views() 

1211 current_idx = view_names.index(self.active_view) 

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

1213 

1214 

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

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

1217 return cast(LilbeeApp, host_app).set_active_model(key, value) 

1218 

1219 

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

1221 """Route non-model settings writes through LilbeeApp.set_setting; True when saved.""" 

1222 return cast(LilbeeApp, host_app).set_setting(key, value)