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

1405 statements  

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

1"""Chat screen: scrollable message log with streaming markdown responses.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6import contextlib 

7import difflib 

8import logging 

9import os 

10import shlex 

11import threading 

12import time 

13from collections.abc import Callable 

14from dataclasses import dataclass 

15from pathlib import Path 

16from typing import TYPE_CHECKING, Any, ClassVar 

17 

18from rich.rule import Rule 

19from textual import events, getters, on, work 

20from textual.actions import SkipAction 

21from textual.app import ComposeResult 

22from textual.binding import Binding, BindingType 

23from textual.containers import Horizontal, Vertical, VerticalScroll 

24from textual.content import Content 

25from textual.css.query import NoMatches 

26from textual.dom import DOMNode 

27from textual.reactive import reactive 

28from textual.screen import Screen 

29from textual.widgets import Footer, Markdown, Select, Static 

30 

31# Cancellation check for @work(thread=True) workers. Import at module level 

32# since it's used in multiple methods. 

33from textual.worker import NoActiveWorker 

34from textual.worker import get_current_worker as _get_worker 

35 

36from lilbee.app.services import get_services, reset_store 

37from lilbee.app.settings_map import SETTINGS_MAP 

38from lilbee.app.themes import DARK_THEMES 

39from lilbee.app.version import get_version 

40from lilbee.cli.tui import messages as msg 

41from lilbee.cli.tui.app import LilbeeApp, apply_active_model 

42from lilbee.cli.tui.command_registry import runs_while_streaming 

43from lilbee.cli.tui.screens.chat_helpers import ( 

44 add_indexed_anything, 

45 build_add_progress_callback, 

46 build_import_progress_callback, 

47 build_sync_progress_callback, 

48 close_stream, 

49 open_local_file, 

50 remember_from_input, 

51 unregister_added_roots, 

52) 

53from lilbee.cli.tui.thread_safe import call_from_thread 

54from lilbee.cli.tui.widgets.arg_hint import ArgHintLine 

55from lilbee.cli.tui.widgets.autocomplete import ( 

56 PATH_ARG_COMMANDS, 

57 CompletionOverlay, 

58 get_completions, 

59 longest_common_prefix, 

60 path_completion_prefix, 

61) 

62from lilbee.cli.tui.widgets.chat_input import ChatInput 

63from lilbee.cli.tui.widgets.context_chip import ContextChip 

64from lilbee.cli.tui.widgets.drawer import Drawer 

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

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

67from lilbee.cli.tui.widgets.help_hint import HelpHint 

68from lilbee.cli.tui.widgets.message import AssistantMessage, UserMessage 

69from lilbee.cli.tui.widgets.model_bar import ChatModeToggle, ModelBar 

70from lilbee.cli.tui.widgets.slash_command_catalog import SlashCommandCatalog 

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

72from lilbee.cli.tui.widgets.task_bar import TaskBar 

73from lilbee.cli.tui.widgets.task_bar_controller import ProgressReporter 

74from lilbee.core.config import cfg 

75from lilbee.core.config.enums import ChatMode, CrawlRenderMode 

76from lilbee.crawler import crawler_available, is_url, require_valid_crawl_url 

77from lilbee.data.store import ( 

78 ChunkType, 

79 EmbeddingModelMismatchError, 

80 SearchScope, 

81 scope_to_chunk_type, 

82) 

83from lilbee.providers.roles import WorkerRole 

84from lilbee.providers.warm_progress import WarmPhase, WarmProgress 

85from lilbee.retrieval.embedder import is_model_available 

86from lilbee.retrieval.query import SOURCES_BLOCK_MARKER, ChatMessage 

87from lilbee.retrieval.query.compaction import ( 

88 compaction_due, 

89 foldable, 

90 history_budget, 

91 overflow, 

92 prompt_history, 

93 summary_messages, 

94) 

95from lilbee.retrieval.query.history_window import estimate_tokens 

96from lilbee.retrieval.reasoning import RetrievalNotice 

97from lilbee.runtime import asyncio_loop 

98from lilbee.runtime.progress import ( 

99 EventType, 

100 ProgressEvent, 

101) 

102from lilbee.sessions import ( 

103 MessageRole, 

104 SessionMessage, 

105 SessionNotFoundError, 

106 SessionOrigin, 

107 SessionStore, 

108 TitleSource, 

109 derive_title, 

110) 

111 

112if TYPE_CHECKING: 

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

114log = logging.getLogger(__name__) 

115 

116# Coalesce per-token UI updates into ~50 ms windows. Tiny reasoning models can 

117# emit 100+ tokens/sec; one ``call_from_thread`` per token saturates Textual's 

118# message queue and makes key events visibly lag. 

119_STREAM_FLUSH_INTERVAL = 0.05 

120 

121 

122@dataclass 

123class _StreamTimings: 

124 """Last-fired monotonic timestamp for the stream flush.""" 

125 

126 last_flush: float 

127 

128 

129# ``/crawl`` command flags. 

130_CRAWL_FLAG_DEPTH = "--depth" 

131_CRAWL_FLAG_MAX_PAGES = "--max-pages" 

132_CRAWL_FLAG_INCLUDE_SUBDOMAINS = "--include-subdomains" 

133_CRAWL_FLAG_RENDER = "--render" 

134 

135# Name for the thread worker that resets and warms the new chat model off the 

136# event loop. 

137_MODEL_SWAP_WORKER = "model_swap_reset" 

138 

139 

140def _engine_status_text(snapshot: WarmProgress) -> str: 

141 """One status line for an engine-load snapshot: byte progress or the phase.""" 

142 if snapshot.phase is WarmPhase.READING_WEIGHTS and snapshot.bytes_total: 

143 from lilbee.catalog.formatting import display_label_for_ref 

144 

145 name = display_label_for_ref(snapshot.model_ref) if snapshot.model_ref else "" 

146 pct = snapshot.bytes_done * 100 // snapshot.bytes_total 

147 return f"{msg.ENGINE_READING_WEIGHTS.format(name=name)} {pct}%" 

148 if snapshot.phase is WarmPhase.LOADING_ENGINE: 

149 return msg.ENGINE_ALMOST_READY 

150 return msg.ENGINE_WARMING 

151 

152 

153_SETTING_TYPE_HINTS: dict[type, str] = {int: "a whole number", float: "a number"} 

154 

155 

156def _setting_type_hint(kind: type) -> str: 

157 """Human phrase for what a settings value must be.""" 

158 return _SETTING_TYPE_HINTS.get(kind, f"a valid {kind.__name__} value") 

159 

160 

161def _closest_source(name: str, known: set[str]) -> str | None: 

162 """The indexed name most likely meant by *name*, or None when nothing is close.""" 

163 low = name.lower() 

164 contains = [k for k in known if low in k.lower()] 

165 if len(contains) == 1: 

166 return contains[0] 

167 matches = difflib.get_close_matches(name, sorted(known), n=1, cutoff=0.6) 

168 return matches[0] if matches else None 

169 

170 

171def _parse_add_paths(args: str) -> list[Path]: 

172 """Resolve ``/add`` arguments to filesystem paths. 

173 

174 A single unquoted path may contain spaces and apostrophes (e.g. macOS 

175 "Star Wars Collector's Edition.pdf"), which shell parsing would split into 

176 fragments or reject with "No closing quotation". So when the whole argument 

177 points at an existing file or directory, take it as one path; otherwise fall 

178 back to shell-style splitting for multiple, optionally quoted, paths. 

179 """ 

180 whole = Path(args.strip().strip('"').strip("'")).expanduser() 

181 if whole.exists(): 

182 return [whole] 

183 try: 

184 # posix=False on Windows keeps backslash path separators literal. 

185 tokens = shlex.split(args, posix=os.name != "nt") 

186 except ValueError: 

187 return [whole] # unbalanced quote in a literal path; treat as one path 

188 if os.name == "nt": 

189 tokens = [t.strip('"').strip("'") for t in tokens] 

190 return [Path(token).expanduser() for token in tokens] 

191 

192 

193class ChatWelcome(Static): 

194 """Empty-state welcome posted into the chat log; removed on first message.""" 

195 

196 def __init__(self, *, id: str | None = None) -> None: 

197 super().__init__(self._body(msg.CHAT_WELCOME_HINT), id=id) 

198 

199 @staticmethod 

200 def _body(hint_text: str) -> Content: 

201 title = Content.styled(msg.CHAT_WELCOME_TITLE, "bold $primary") 

202 tagline = Content.styled(msg.CHAT_WELCOME_TAGLINE, "$text-muted") 

203 hint = Content.styled(hint_text, "$text-muted") 

204 return Content.assemble(title, "\n", tagline, "\n\n", hint) 

205 

206 def set_no_model(self, no_model: bool) -> None: 

207 """Swap the hint line between "just ask" and the route to a chat model.""" 

208 hint = msg.CHAT_WELCOME_NO_MODEL_HINT if no_model else msg.CHAT_WELCOME_HINT 

209 self.update(self._body(hint)) 

210 

211 

212class PromptArea(Vertical): 

213 """Container for chat input that highlights on focus-within.""" 

214 

215 pass 

216 

217 

218class ChatScreen(Screen[None]): 

219 """Primary chat interface with streaming LLM responses.""" 

220 

221 # Lilbee always hosts screens on a LilbeeApp (production + LilbeeAppHost 

222 # in tests), so narrowing the type lets the screen call set_theme / 

223 # switch_view / task_bar without isinstance dance or # type: ignore. 

224 app: LilbeeApp # type: ignore[assignment] 

225 

226 CSS_PATH = "chat.tcss" 

227 AUTO_FOCUS = "#chat-input" 

228 

229 streaming: reactive[bool] = reactive(False) 

230 # True while a chat-model swap's fleet reload runs in the background. Gates the 

231 # submit handler and disables the input so the user can't fire a prompt into a 

232 # half-loaded fleet; cleared when the swap worker finishes (or fails). 

233 swapping_model: reactive[bool] = reactive(False) 

234 # True while a placement apply/clear reloads the fleet (from the Fleet drawer); 

235 # holds chat submissions so they don't race the reload into a 429. 

236 reloading_placement: reactive[bool] = reactive(False) 

237 

238 HELP = ( 

239 "# Chat\n\n" 

240 "Ask questions about your knowledge base.\n\n" 

241 "Press **Escape** for normal mode (vim keys), " 

242 "**i**/**a**/**o** to return to insert mode.\n\n" 

243 "**/** opens the slash-command line and **Tab** completes what you " 

244 "type there; **F2** lists every command.\n\n" 

245 "**F6** jumps to the model strip under the prompt, and in normal mode " 

246 "**h** / **l** or **Left** / **Right** step into it from either end. " 

247 "**Left** / **Right** walk all six cells (the Chat, Embed, Vision and " 

248 "Rerank pickers, then the Search and Chat mode pills), **h** / **l** do " 

249 "the same, **Home** / **End** jump to either end, **Enter** opens or " 

250 "picks the focused cell, **Escape** goes back." 

251 ) 

252 

253 _SCROLL_GROUP = Binding.Group("Scroll", compact=True) 

254 

255 # Hot-path widget refs. ``getters.query_one`` is a typed class-level 

256 # descriptor that resolves via Textual's indexed DOM lookup on every 

257 # access. It is O(1) for id selectors, so no cache is needed. 

258 _chat_input = getters.query_one("#chat-input", ChatInput) 

259 _chat_log = getters.query_one("#chat-log", VerticalScroll) 

260 _completion_overlay = getters.query_one("#completion-overlay", CompletionOverlay) 

261 _arg_hint = getters.query_one("#arg-hint", ArgHintLine) 

262 

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

264 # `/` opens the slash-command line: the one thing this screen is for 

265 # besides typing, so it keeps a footer cell. 

266 Binding("slash", "focus_commands", "Commands", show=True), 

267 # F2 opens the searchable list of every slash command 

268 # (SlashCommandCatalog) -- not the model catalog, which is `/models`. 

269 # Help-panel only: `/` already leads there, and the full list is a lookup. 

270 Binding( 

271 "f2", 

272 "show_command_catalog", 

273 "All commands", 

274 show=False, 

275 priority=True, 

276 ), 

277 # Hidden: Tab only completes while the slash dropdown is open, and the 

278 # rest of the time it walks the focus chain, so a permanent 

279 # "tab Complete" cell overstated it. Named in help beside `/`. 

280 Binding("tab", "complete", "Complete", show=False, priority=True), 

281 Binding("ctrl+n", "complete_next", "Next match", show=False, priority=True), 

282 # Ctrl+P stays bound to the app's command palette by default. The 

283 # chat screen only intercepts it WHEN the dropdown is visible, via 

284 # LilbeeApp.action_command_palette overriding to call 

285 # ChatScreen.action_complete_prev. Action is exposed for direct 

286 # callers / tests; not bound here so the app-level priority binding 

287 # for ctrl+p (palette) wins by default. 

288 Binding("pageup", "scroll_up", "PgUp", show=False, group=_SCROLL_GROUP), 

289 Binding("pagedown", "scroll_down", "PgDn", show=False, group=_SCROLL_GROUP), 

290 Binding("ctrl+d", "half_page_down", "^d half PgDn", show=False, group=_SCROLL_GROUP), 

291 Binding("ctrl+u", "half_page_up", "^u half PgUp", show=False, group=_SCROLL_GROUP), 

292 Binding("j", "vim_scroll_down", "j down", show=False, group=_SCROLL_GROUP), 

293 Binding("k", "vim_scroll_up", "k up", show=False, group=_SCROLL_GROUP), 

294 Binding("g", "vim_scroll_home", "g top", show=False, group=_SCROLL_GROUP), 

295 Binding("G", "vim_scroll_end", "G bottom", show=False, group=_SCROLL_GROUP), 

296 # priority=True keeps history navigation fast-path winning over the 

297 # ChatInput's TextArea cursor_up/_down. Multi-line cursor movement 

298 # inside the prompt still works via PgUp/PgDn/Home/End. 

299 Binding("up", "history_prev", "Up", show=False, priority=True), 

300 Binding("down", "history_next", "Down", show=False, priority=True), 

301 # Esc always drops back into NORMAL mode so the user can navigate 

302 # the terminal. Cancel-while-streaming is on Ctrl+C below; the 

303 # two roles used to share Esc and clobbered each other. 

304 Binding("escape", "enter_normal_mode", "Normal mode", show=True, priority=True), 

305 # Ctrl+C cancels the active stream when streaming AND in INSERT 

306 # mode so the user can interrupt without leaving the input. The 

307 # screen-level priority binding overrides the App-level Quit; 

308 # check_action below hides + disables it outside that exact 

309 # context, so Ctrl+C still quits the app from NORMAL or when 

310 # nothing is streaming. 

311 Binding("ctrl+c", "cancel_stream", "Cancel stream", show=True, priority=True), 

312 Binding("ctrl+r", "toggle_markdown", "Markdown", show=False), 

313 Binding("s", "cycle_scope", "Scope", show=False), 

314 Binding("f3", "toggle_chat_mode", "Search/Chat", show=False), 

315 # A function key, not a letter: the four role pickers are worth 

316 # reaching mid-sentence, and a focused input consumes printable keys 

317 # before any binding fires. Tab reaches the bar too, but only from 

318 # NORMAL mode and only after walking past the log. 

319 Binding("f6", "focus_model_bar", "Model bar", show=False, priority=True), 

320 # NORMAL mode walks sideways into the role strip. h / l rather than the 

321 # whole of hjkl: the transcript owns j / k for scrolling. 

322 Binding("h", "enter_model_strip(-1)", "Prev role", show=False), 

323 Binding("l", "enter_model_strip(1)", "Next role", show=False), 

324 # The arrows reach here too. The focused transcript is a VerticalScroll 

325 # and binds Left / Right to horizontal scrolling, but Widget's 

326 # action_scroll_left raises SkipAction when there is nothing to scroll 

327 # sideways, which resumes the key lookup and lands it here. A transcript 

328 # wide enough to scroll keeps its own arrows; h / l are unconditional. 

329 Binding("left", "enter_model_strip(-1)", "Prev role", show=False), 

330 Binding("right", "enter_model_strip(1)", "Next role", show=False), 

331 ] 

332 

333 def __init__(self) -> None: 

334 super().__init__() 

335 self._history: list[ChatMessage] = [] 

336 # Rolling summary of the turns compaction has folded out of _history. 

337 # Guarded by _history_lock alongside the turns it stands in for. 

338 self._summary = "" 

339 self._history_lock = threading.Lock() 

340 # The saved session this conversation persists to. None until the first 

341 # user turn creates one; reset to None on /clear so the next turn opens a 

342 # fresh session. 

343 self._session_id: str | None = None 

344 self._insert_mode: bool = True 

345 # Count of programmatic input edits whose (async) Changed events should 

346 # not re-filter the dropdown. The setter posts Changed after our flag 

347 # window would close, so a counter consumed in the handler is used. 

348 self._suppress_refresh = 0 

349 # The user-typed text the open dropdown is filtering against. While 

350 # navigating, the input holds a previewed candidate; Esc restores this. 

351 self._completion_origin: str | None = None 

352 self._sync_active: bool = False 

353 self._input_history: list[str] = [] 

354 self._history_index: int = -1 

355 # The warm tip is worth one toast per session, on the first prompt that 

356 # has to wait out a cold engine load. 

357 self._warm_tip_shown: bool = False 

358 # The bubble receiving the in-flight response, so a cancel can leave a 

359 # visible note in it instead of letting the turn die silently. 

360 self._active_assistant: AssistantMessage | None = None 

361 # The live turn's question; a context boundary mounts above it, never 

362 # after it. Outlives its turn like _active_assistant (next send 

363 # overwrites, reset clears). Never clear it in _finalize_stream: the 

364 # input unblocks first, so the clear races the next turn's question. 

365 self._active_question: UserMessage | None = None 

366 # A model switch asked for mid-answer, applied once the stream ends. 

367 self._model_switch_queued: bool = False 

368 self._command_handlers: dict[str, Callable[[str], None]] = self._build_command_handlers() 

369 

370 def _build_command_handlers(self) -> dict[str, Callable[[str], None]]: 

371 """Bind every COMMANDS entry to its handler method on this instance. 

372 

373 Run once at construction so /handle_slash dispatches via direct method 

374 reference (no per-call getattr-by-string-name reflection). 

375 """ 

376 from lilbee.cli.tui.command_registry import COMMANDS 

377 

378 handlers: dict[str, Callable[[str], None]] = {} 

379 for cmd in COMMANDS: 

380 method = getattr(self, cmd.handler) 

381 for name in (cmd.name, *cmd.aliases): 

382 handlers[name] = method 

383 return handlers 

384 

385 @property 

386 def _task_bar(self) -> TaskBarController: 

387 """The app-level TaskBarController (always set by LilbeeApp).""" 

388 return self.app.task_bar 

389 

390 def compose(self) -> ComposeResult: 

391 from lilbee.cli.tui.widgets.bottom_bars import BottomBars 

392 from lilbee.cli.tui.widgets.scope_chip import ScopeChip 

393 from lilbee.cli.tui.widgets.top_bars import TopBars 

394 

395 with TopBars(): 

396 yield ViewTabs() 

397 yield VerticalScroll( 

398 ChatWelcome(id="chat-welcome"), 

399 id="chat-log", 

400 ) 

401 with BottomBars(): 

402 # Sits directly above the prompt area so it never covers the line 

403 # you're typing (the input stays pinned to the bottom edge). 

404 yield CompletionOverlay(id="completion-overlay") 

405 with PromptArea(id="chat-prompt-area"): 

406 yield ScopeChip(id="scope-chip") 

407 yield ChatInput( 

408 placeholder=msg.CHAT_INPUT_PLACEHOLDER_DEFAULT, 

409 id="chat-input", 

410 ) 

411 yield ArgHintLine(id="arg-hint") 

412 yield ModelBar(id="model-bar") 

413 yield TaskBar() 

414 # The context reading shares the hint band instead of costing the 

415 # prompt block its own row. 

416 with Horizontal(id="hint-row"): 

417 yield HelpHint(id="help-hint") 

418 yield ContextChip(id="context-chip") 

419 yield Footer() 

420 

421 def on_mount(self) -> None: 

422 self._update_input_style() 

423 self.app.settings_changed_signal.subscribe(self, self._on_settings_changed) 

424 # init=True paints the empty state on first mount when the gate landed 

425 # the app on the catalog and the user navigated here without a model. 

426 self.watch(self.app, "chat_is_ready", self._on_chat_ready_changed, init=True) 

427 

428 def on_show(self) -> None: 

429 """Called when screen becomes visible.""" 

430 from lilbee.runtime.splash import dismiss 

431 

432 dismiss() 

433 self.refresh_model_bar() 

434 # AUTO_FOCUS only fires once on initial mount. Re-entering the 

435 # screen via view-nav needs an explicit focus restore. In INSERT 

436 # mode we send focus to the chat input; in NORMAL mode we send 

437 # focus to the chat log (the input is intentionally unfocusable 

438 # so global bindings keep firing). 

439 with contextlib.suppress(Exception): 

440 if self._insert_mode: 

441 self._enter_insert_mode() 

442 else: 

443 self._chat_log.focus() 

444 

445 def _embedding_ready(self) -> bool: 

446 """Quick check if the embedding model resolves (no network calls).""" 

447 return is_model_available(cfg.embedding_model, get_services().provider) 

448 

449 def _on_settings_changed(self, payload: tuple[str, object]) -> None: 

450 key, _value = payload 

451 if key in {"chat_mode", "embedding_model"}: 

452 self.refresh_model_bar() 

453 

454 def _on_chat_ready_changed(self, ready: bool) -> None: 

455 """Paint or clear the no-model empty state as readiness changes.""" 

456 with contextlib.suppress(NoMatches): 

457 self.query_one("#chat-welcome", ChatWelcome).set_no_model(not ready) 

458 self._apply_input_busy_state() 

459 

460 def _enter_insert_mode(self) -> None: 

461 """Switch to insert mode: focus input, update border style.""" 

462 self._insert_mode = True 

463 self._chat_input.can_focus = True 

464 self._chat_input.focus() 

465 self._update_input_style() 

466 

467 def focus_prompt(self) -> None: 

468 """Return focus to the chat input in INSERT mode. 

469 

470 Called when a modal (the model picker) closes: the next act is typing 

471 a prompt, so focus must not stay parked on the widget that opened it. 

472 """ 

473 self._enter_insert_mode() 

474 

475 def action_focus_model_bar(self) -> None: 

476 """F6: put the cursor on the model strip. Left / Right walk it from there.""" 

477 self.query_one("#model-bar", ModelBar).focus_strip() 

478 

479 def action_enter_model_strip(self, direction: int) -> None: 

480 """h / l and the arrows from NORMAL mode: step in from the matching side. 

481 

482 Only reached while focus is outside the bar. Once a role holds the 

483 cursor the bar's own keys win, being nearer the focus. 

484 """ 

485 self.query_one("#model-bar", ModelBar).focus_strip(direction) 

486 

487 def run_command(self, text: str) -> None: 

488 """Dispatch *text* as a slash command, as if submitted from the prompt.""" 

489 if self._reject_submit_when_busy(text): 

490 return 

491 self._handle_slash(text) 

492 

493 def _update_input_style(self) -> None: 

494 """Toggle input opacity and mode indicator based on current mode.""" 

495 # Lifecycle interleaves (an installed-but-swapped-away screen during 

496 # app teardown) can invoke this before or after the input exists. 

497 with contextlib.suppress(NoMatches): 

498 inp = self._chat_input 

499 if self._insert_mode: 

500 inp.remove_class("normal-mode") 

501 else: 

502 inp.add_class("normal-mode") 

503 self._update_mode_indicator() 

504 

505 def _update_mode_indicator(self) -> None: 

506 """Update the ViewTabs mode text to reflect the current mode.""" 

507 with contextlib.suppress(NoMatches): 

508 bar = self.query_one(ViewTabs) 

509 bar.mode_text = msg.MODE_INSERT if self._insert_mode else msg.MODE_NORMAL 

510 

511 def on_key(self, event: object) -> None: 

512 """Handle key events: vim mode and typing from chat log.""" 

513 from textual.events import Key 

514 

515 if not isinstance(event, Key): 

516 return 

517 inp = self._chat_input 

518 if self._insert_mode: 

519 if not inp.has_focus and event.is_printable and event.character: 

520 inp.focus() 

521 inp.insert(event.character) 

522 event.prevent_default() 

523 event.stop() 

524 return 

525 if event.key == "enter" or (event.character and event.character in "iao"): 

526 # Let a focused Select, or anything on the model strip, handle Enter 

527 # itself; i/a/o mean nothing to those widgets, so they always return 

528 # to INSERT. Asked of the bar rather than of a list of widget types: 

529 # the mode pills were missing from that list and Enter on a pill 

530 # dropped to INSERT instead of switching the mode. 

531 if event.key == "enter" and ( 

532 isinstance(self.focused, Select) or self._focus_in_model_bar() 

533 ): 

534 return 

535 if self._focus_in_drawer(): 

536 return 

537 self._enter_insert_mode() 

538 if event.key == "enter" and inp.value.strip(): 

539 # Enter meant "send": submit the draft the user Esc'd over 

540 # instead of stranding it invisibly in the dimmed input. 

541 self._submit_draft(inp, inp.value) 

542 event.prevent_default() 

543 event.stop() 

544 return 

545 

546 @on(events.DescendantFocus, "#chat-input") 

547 def _on_chat_input_focused(self, event: events.DescendantFocus) -> None: 

548 """Mark INSERT mode whenever the chat input takes focus. 

549 

550 With ``can_focus = False`` while in NORMAL mode, the only way the 

551 input gains focus is via an explicit user action (click, or the 

552 :meth:`_enter_insert_mode` helper that sets ``can_focus = True`` 

553 and focuses the input). Either path implies INSERT, so we sync 

554 the screen mode here. 

555 """ 

556 if not self._insert_mode: 

557 self._enter_insert_mode() 

558 

559 @on(events.Click, "#chat-input") 

560 def _on_chat_input_clicked(self, event: events.Click) -> None: 

561 """Click on the chat input bar promotes to INSERT. 

562 

563 ``can_focus = False`` while in NORMAL mode swallows focus from the 

564 click, so DescendantFocus never fires. Hook the Click directly so 

565 a mouse user lands in INSERT just like a keystroke (i / a / o). 

566 """ 

567 if not self._insert_mode: 

568 self._enter_insert_mode() 

569 event.stop() 

570 

571 def on_click(self, event: events.Click) -> None: 

572 """Click outside the chat input bar drops back to NORMAL. 

573 

574 The chat-input click handler above promotes to INSERT; the 

575 symmetric exit happens here so a mouse user gets the same 

576 click-to-blur behavior they expect from any other text editor. 

577 """ 

578 if not self._insert_mode: 

579 return 

580 if event.widget is None: 

581 return 

582 chat_input = self._chat_input 

583 node: DOMNode | None = event.widget 

584 while node is not None: 

585 if node is chat_input: 

586 return 

587 node = node.parent 

588 self.action_enter_normal_mode() 

589 

590 @on(ChatInput.Submitted, "#chat-input") 

591 def _on_chat_submitted(self, event: ChatInput.Submitted) -> None: 

592 if not self._insert_mode: 

593 # Vim-style: Enter in normal mode flips back to insert without 

594 # submitting whatever empty / stale text the input still holds. 

595 self._enter_insert_mode() 

596 return 

597 self._submit_draft(event.chat_input, event.value) 

598 

599 def _submit_draft(self, chat_input: ChatInput, value: str) -> None: 

600 """Send *value* as a command or message once the submit gate allows it.""" 

601 text = value.strip() 

602 if not self._ready_to_submit(text): 

603 return 

604 chat_input.value = "" 

605 self._input_history.append(text) 

606 self._history_index = -1 

607 

608 if text.startswith("/"): 

609 self._handle_slash(text) 

610 return 

611 self._send_message(text) 

612 

613 def _ready_to_submit(self, text: str) -> bool: 

614 """Gate a submit: busy, consumed, empty, and keep-the-draft cases say no.""" 

615 if self._reject_submit_when_busy(text) or self._dismiss_overlay_on_submit() or not text: 

616 return False 

617 cmd = self._slash_name(text) 

618 if cmd: 

619 if cmd not in self._command_handlers: 

620 # Keep the draft so a typo (or a stale leading slash) can be 

621 # fixed in place instead of retyped. 

622 self.notify(msg.CMD_UNKNOWN.format(cmd=cmd), severity="warning") 

623 return False 

624 return True 

625 pending = self._pending_required_model_download() 

626 if pending is not None: 

627 # Keep the typed prompt in the input so the user can submit it 

628 # again once the download finishes, instead of retyping it. 

629 self.notify( 

630 msg.CHAT_MODEL_DOWNLOADING.format(name=pending), 

631 severity="warning", 

632 timeout=5, 

633 ) 

634 return False 

635 return True 

636 

637 def _reject_submit_when_busy(self, text: str = "") -> bool: 

638 """Toast and reject a submit while a swap is loading or a stream is in flight. 

639 

640 Returns True when the submit was rejected so the caller stops. The swap 

641 check comes first: a prompt sent mid-swap would race a half-torn-down 

642 fleet, so the user is asked to wait rather than cancel. Commands the 

643 registry marks ``allowed_while_streaming`` pass the streaming check, so 

644 /cancel and /model stay reachable during the turn they act on. 

645 """ 

646 if self.swapping_model: 

647 self.notify(msg.CHAT_MODEL_SWITCHING, severity="warning", timeout=3) 

648 return True 

649 if self.reloading_placement: 

650 self.notify(msg.FLEET_RELOADING, severity="warning", timeout=3) 

651 return True 

652 if self.streaming and not runs_while_streaming(self._slash_name(text)): 

653 # Only one chat message may be in flight at a time; surface a toast 

654 # so the prompt is visibly rejected, not silently dropped. 

655 self.notify(msg.CHAT_BUSY, severity="warning", timeout=3) 

656 return True 

657 return False 

658 

659 @staticmethod 

660 def _slash_name(text: str) -> str: 

661 """The command word of a slash submit, or "" when *text* is not one.""" 

662 return text.split()[0].lower() if text.startswith("/") else "" 

663 

664 def _pending_required_model_download(self) -> str | None: 

665 """Return the in-flight download's name if it's for the configured chat or embedding model. 

666 

667 Covers the fresh-install case where the default ``cfg.chat_model`` 

668 points at a featured catalog ref whose file isn't on disk yet, 

669 but a wizard-triggered download for it is queued or active. 

670 """ 

671 task_bar = self.app.task_bar 

672 for ref in (cfg.chat_model, cfg.embedding_model): 

673 label = task_bar.downloading_label_for(ref) 

674 if label is not None: 

675 return label 

676 return None 

677 

678 def _dismiss_overlay_on_submit(self) -> bool: 

679 """Close the dropdown on Enter; consume only a bare slash, never a message. 

680 

681 Enter submits exactly what was typed. Tab and the arrow keys are the 

682 completion gestures, and a previewed candidate is already in the input, 

683 so a highlighted-but-unaccepted suggestion must never rewrite or swallow 

684 a submission. 

685 """ 

686 overlay = self._completion_overlay 

687 if overlay.is_visible: 

688 overlay.hide() 

689 self._completion_origin = None 

690 if self._chat_input.value.strip() == "/": 

691 self._set_input("") 

692 return True 

693 return False 

694 

695 def _handle_slash(self, text: str) -> None: 

696 """Dispatch slash commands via the per-instance handler registry.""" 

697 cmd = text.split()[0].lower() 

698 args = text[len(cmd) :].strip() 

699 handler = self._command_handlers.get(cmd) 

700 if handler is not None: 

701 handler(args) 

702 else: 

703 self.notify(msg.CMD_UNKNOWN.format(cmd=cmd), severity="warning") 

704 

705 def _set_streaming(self, value: bool) -> None: 

706 """Main-thread setter so worker-thread paths can route through ``call_from_thread``.""" 

707 self.streaming = value 

708 

709 def watch_streaming(self, streaming: bool) -> None: 

710 if streaming: 

711 self._enter_streaming_state() 

712 else: 

713 self._exit_streaming_state() 

714 

715 def _enter_streaming_state(self) -> None: 

716 self.add_class("streaming") 

717 # Cancel + finalize both write streaming=False; reactive dedupe 

718 # keeps the watcher a no-op on equal values. 

719 self.refresh_bindings() 

720 

721 def _exit_streaming_state(self) -> None: 

722 self.remove_class("streaming") 

723 self.refresh_bindings() 

724 if self._model_switch_queued: 

725 # Cleared before the call: apply_model_change can re-enter here. 

726 self._model_switch_queued = False 

727 self.apply_model_change() 

728 

729 def _cmd_add(self, args: str) -> None: 

730 from lilbee.app.ingest import source_label_taken 

731 

732 if not args: 

733 return 

734 if self._sync_active: 

735 self.notify(msg.SYNC_ALREADY_ACTIVE, severity="warning") 

736 return 

737 if is_url(args): 

738 self._cmd_crawl(args) 

739 return 

740 paths = _parse_add_paths(args) 

741 missing = [p for p in paths if not p.exists()] 

742 if missing: 

743 self.notify( 

744 msg.CMD_ADD_NOT_FOUND.format(path=", ".join(str(p) for p in missing)), 

745 severity="error", 

746 ) 

747 return 

748 # A file add registers a root labeled by its basename. Prompt before 

749 # overwriting only when that label is already taken by a different source 

750 # (a live root elsewhere, or an owned file of that name); re-adding the 

751 # same path is idempotent, and a directory is left to register_sources' 

752 # own skipped notices rather than a duplicate-file prompt. 

753 duplicates = [p for p in paths if p.is_file() and source_label_taken(p.name, p)] 

754 if duplicates: 

755 self._prompt_overwrite(paths, duplicates) 

756 return 

757 self._submit_add(paths, force=False) 

758 

759 def _prompt_overwrite(self, paths: list[Path], duplicates: list[Path]) -> None: 

760 """Ask to overwrite existing copies before re-syncing.""" 

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

762 

763 names = ", ".join(p.name for p in duplicates) 

764 

765 def _on_confirm(confirmed: bool | None) -> None: 

766 if not confirmed: 

767 self.notify(msg.CMD_ADD_SKIPPED_DUPLICATE.format(name=names)) 

768 return 

769 self._submit_add(paths, force=True) 

770 

771 self.app.push_screen( 

772 ConfirmDialog( 

773 msg.CMD_ADD_DUPLICATE_TITLE, 

774 msg.CMD_ADD_DUPLICATE_MESSAGE.format(name=names), 

775 ), 

776 _on_confirm, 

777 ) 

778 

779 def _submit_add(self, paths: list[Path], *, force: bool) -> None: 

780 """Spawn the add worker. Separated so overwrite confirm can reuse it.""" 

781 from lilbee.cli.tui.task_queue import TaskType 

782 

783 self._sync_active = True 

784 label = paths[0].name if len(paths) == 1 else f"{len(paths)} files" 

785 

786 def _target(reporter: ProgressReporter) -> None: 

787 try: 

788 self._do_add(paths, reporter, force=force) 

789 finally: 

790 self._sync_active = False 

791 

792 self._task_bar.start_task(f"Add {label}", TaskType.ADD, _target, indeterminate=True) 

793 

794 def _do_add( 

795 self, paths: list[Path], reporter: ProgressReporter, *, force: bool = False 

796 ) -> None: 

797 """Register source roots and run sync. Called on worker thread with a reporter.""" 

798 from lilbee.app.ingest import register_sources 

799 from lilbee.data.ingest import sync 

800 

801 label = paths[0].name if len(paths) == 1 else f"{len(paths)} files" 

802 reporter.update(0, f"Adding {label}...", indeterminate=True) 

803 reg_result = register_sources(paths, force=force) 

804 registered = reg_result.registered 

805 for name in reg_result.skipped: 

806 call_from_thread(self, self.notify, msg.CMD_ADD_NAME_TAKEN.format(name=name)) 

807 if reg_result.tracked: 

808 call_from_thread( 

809 self, self.notify, msg.CMD_ADD_TRACKED.format(names=", ".join(reg_result.tracked)) 

810 ) 

811 reporter.update(0, f"Added {len(registered)} source(s), syncing...", indeterminate=True) 

812 

813 try: 

814 sync_result = asyncio_loop.run( 

815 sync(quiet=True, on_progress=build_add_progress_callback(reporter)) 

816 ) 

817 except BaseException: 

818 # On cancel or any failure, un-register the roots this /add created so 

819 # the next sync doesn't silently re-ingest the source the user just 

820 # cancelled. Only entries this invocation created are dropped; 

821 # sources the user put in documents/ themselves are never touched. 

822 unregister_added_roots(registered) 

823 raise 

824 if sync_result.failed: 

825 unregister_added_roots(registered) 

826 raise RuntimeError(msg.SYNC_FAILED_FILES.format(files=", ".join(sync_result.failed))) 

827 if sync_result.skipped: 

828 # Files yielding no text beside indexed siblings are a partial 

829 # success; only an add whose own roots contributed nothing failed. 

830 skipped_msg = msg.sync_skipped_message(", ".join(sync_result.skipped)) 

831 if registered and not add_indexed_anything(registered, sync_result): 

832 unregister_added_roots(registered) 

833 raise RuntimeError(skipped_msg) 

834 call_from_thread(self, self.notify, skipped_msg, severity="warning") 

835 if sync_result.relocated: 

836 call_from_thread( 

837 self, 

838 self.notify, 

839 msg.CMD_ADD_RELOCATED.format(count=len(sync_result.relocated)), 

840 ) 

841 call_from_thread(self, self.notify, msg.CMD_ADD_SUCCESS.format(count=len(registered))) 

842 

843 def _cmd_cancel(self, _args: str) -> None: 

844 # _cancel_inflight_stream already cancels every screen worker, so the 

845 # two branches each cancel everything exactly once. 

846 if self.streaming: 

847 self._cancel_inflight_stream(msg.STREAM_CANCELLED) 

848 else: 

849 for worker in self.workers: 

850 worker.cancel() 

851 self.notify(msg.CMD_CANCEL) 

852 

853 def _cmd_clear(self, _args: str) -> None: 

854 self._reset_conversation() 

855 self.notify(msg.CMD_CLEAR) 

856 

857 def _reset_conversation(self) -> None: 

858 """Cancel any stream, empty the log and history, and drop the active session. 

859 

860 The current session is already persisted, so dropping the id just makes the 

861 next user turn open a fresh one. 

862 """ 

863 if self.streaming: 

864 self._cancel_inflight_stream(msg.STREAM_CANCELLED) 

865 else: 

866 for worker in self.workers: 

867 worker.cancel() 

868 self.streaming = False 

869 self._chat_log.remove_children() 

870 self._active_assistant = None 

871 self._active_question = None 

872 with self._history_lock: 

873 self._history.clear() 

874 # A new conversation inherits nothing, least of all the last one's 

875 # summary: carrying it would leak the old chat into the new prompt. 

876 self._summary = "" 

877 self._session_id = None 

878 

879 def _cmd_crawl(self, args: str) -> None: 

880 if not crawler_available(): 

881 self.notify(msg.CMD_CRAWL_UNAVAILABLE, severity="error") 

882 return 

883 if not args: 

884 self._open_crawl_dialog() 

885 return 

886 parts = args.split() 

887 url = parts[0] 

888 if not is_url(url): 

889 url = f"https://{url}" 

890 try: 

891 require_valid_crawl_url(url) 

892 except ValueError as exc: 

893 self.notify(str(exc), severity="error") 

894 return 

895 depth, max_pages, include_subdomains, render_mode = self._parse_crawl_flags(parts[1:]) 

896 self._start_crawl( 

897 url, 

898 depth, 

899 max_pages, 

900 include_subdomains=include_subdomains, 

901 render_mode=render_mode, 

902 ) 

903 

904 def _open_crawl_dialog(self) -> None: 

905 """Push the crawl modal and handle its result.""" 

906 from lilbee.cli.tui.widgets.crawl_dialog import CrawlDialog, CrawlParams 

907 

908 def _on_result(result: CrawlParams | None) -> None: 

909 if result is not None: 

910 self._start_crawl( 

911 result.url, result.depth, result.max_pages, render_mode=result.render_mode 

912 ) 

913 

914 self.app.push_screen(CrawlDialog(), callback=_on_result) 

915 

916 def _start_crawl( 

917 self, 

918 url: str, 

919 depth: int | None, 

920 max_pages: int | None, 

921 *, 

922 include_subdomains: bool = False, 

923 render_mode: CrawlRenderMode | None = None, 

924 ) -> None: 

925 """Enqueue a crawl task and run it in the background. 

926 

927 Bootstrap Chromium first via the controller helper, but only for a 

928 browser-mode crawl. HTTP mode needs no browser, so the SETUP task is 

929 skipped and the crawl starts immediately. An explicit ``render_mode`` 

930 (from the dialog checkbox or ``--render``) is persisted so the choice 

931 sticks for the next crawl. 

932 """ 

933 from lilbee.cli.tui.task_queue import TaskType 

934 

935 mode = render_mode if render_mode is not None else cfg.crawl_render_mode 

936 if render_mode is not None and render_mode is not cfg.crawl_render_mode: 

937 self._persist_crawl_render_mode(render_mode) 

938 

939 def _kick_off_crawl() -> None: 

940 self._task_bar.start_task( 

941 msg.TASK_NAME_CRAWL.format(url=url), 

942 TaskType.CRAWL, 

943 lambda reporter: self._do_crawl( 

944 url, 

945 depth, 

946 max_pages, 

947 reporter, 

948 include_subdomains=include_subdomains, 

949 render_mode=mode, 

950 ), 

951 on_success=lambda: call_from_thread(self, self._run_sync), 

952 ) 

953 

954 self.notify(msg.CMD_CRAWL_STARTED.format(url=url)) 

955 if mode is CrawlRenderMode.BROWSER: 

956 self._task_bar.ensure_chromium(_kick_off_crawl) 

957 else: 

958 _kick_off_crawl() 

959 

960 def _persist_crawl_render_mode(self, render_mode: CrawlRenderMode) -> None: 

961 """Persist the chosen render mode so the dialog checkbox stays sticky.""" 

962 from lilbee.app.settings import apply_settings_update 

963 

964 try: 

965 apply_settings_update({"crawl_render_mode": render_mode.value}) 

966 except (ValueError, OSError) as exc: 

967 log.warning("Could not persist crawl_render_mode: %s", exc) 

968 

969 @staticmethod 

970 def _parse_crawl_flags( 

971 tokens: list[str], 

972 ) -> tuple[int | None, int | None, bool, CrawlRenderMode | None]: 

973 """Extract --depth, --max-pages, --include-subdomains, --render from tokens. 

974 

975 Numeric flags return None when absent so the caller inherits 

976 crawl_and_save's unbounded-by-default semantics. The boolean 

977 ``--include-subdomains`` flag defaults to False (exact-host scope). 

978 ``--render http|browser`` returns None when absent so the caller 

979 inherits ``cfg.crawl_render_mode``; an unrecognized value is ignored. 

980 """ 

981 flag_map = {_CRAWL_FLAG_DEPTH: "depth", _CRAWL_FLAG_MAX_PAGES: "max_pages"} 

982 parsed: dict[str, int | None] = {"depth": None, "max_pages": None} 

983 include_subdomains = False 

984 render_mode: CrawlRenderMode | None = None 

985 i = 0 

986 while i < len(tokens): 

987 if tokens[i] == _CRAWL_FLAG_INCLUDE_SUBDOMAINS: 

988 include_subdomains = True 

989 i += 1 

990 continue 

991 if tokens[i] == _CRAWL_FLAG_RENDER and i + 1 < len(tokens): 

992 with contextlib.suppress(ValueError): 

993 render_mode = CrawlRenderMode(tokens[i + 1]) 

994 i += 2 

995 continue 

996 key = flag_map.get(tokens[i]) 

997 if key and i + 1 < len(tokens): 

998 with contextlib.suppress(ValueError): 

999 parsed[key] = int(tokens[i + 1]) 

1000 i += 2 

1001 else: 

1002 i += 1 

1003 return parsed["depth"], parsed["max_pages"], include_subdomains, render_mode 

1004 

1005 def _do_crawl( 

1006 self, 

1007 url: str, 

1008 depth: int | None, 

1009 max_pages: int | None, 

1010 reporter: ProgressReporter, 

1011 *, 

1012 include_subdomains: bool = False, 

1013 render_mode: CrawlRenderMode | None = None, 

1014 ) -> None: 

1015 """Crawl body. Runs on worker thread; reporter handles progress + cancel.""" 

1016 from lilbee.crawler import crawl_and_save 

1017 from lilbee.runtime.progress import CrawlPageEvent, CrawlPageFailedEvent, SetupProgressEvent 

1018 

1019 reporter.update(0, msg.CMD_CRAWL_STARTED.format(url=url)) 

1020 failures: list[str] = [] 

1021 

1022 def on_progress(event_type: EventType, data: ProgressEvent) -> None: 

1023 if event_type == EventType.SETUP_START: 

1024 reporter.update(0, msg.SETUP_CHROMIUM_NAME) 

1025 elif event_type == EventType.SETUP_PROGRESS and isinstance(data, SetupProgressEvent): 

1026 if data.total_bytes: 

1027 pct = int(data.downloaded_bytes * 100 / data.total_bytes) 

1028 detail = msg.SETUP_CHROMIUM_DETAIL.format( 

1029 done=data.downloaded_bytes // (1024 * 1024), 

1030 total=data.total_bytes // (1024 * 1024), 

1031 ) 

1032 else: 

1033 pct = 0 

1034 detail = msg.SETUP_CHROMIUM_DETAIL_UNKNOWN.format( 

1035 done=data.downloaded_bytes // (1024 * 1024), 

1036 ) 

1037 reporter.update(pct, detail) 

1038 elif event_type == EventType.CRAWL_PAGE and isinstance(data, CrawlPageEvent): 

1039 # Discovery hasn't resolved a sitemap yet (data.total <= 0): 

1040 # show the indeterminate spinner with a count, not a parked 

1041 # 50% bar that looks frozen. Switch to a determinate bar as 

1042 # soon as the total is known. 

1043 if data.total > 0: 

1044 pct = int(data.current * 100 / data.total) 

1045 reporter.update( 

1046 pct, 

1047 msg.CMD_CRAWL_PAGE.format( 

1048 current=data.current, total=data.total, url=data.url 

1049 ), 

1050 indeterminate=False, 

1051 ) 

1052 else: # pragma: no cover - live crawl without sitemap 

1053 reporter.update( 

1054 0, 

1055 msg.CMD_CRAWL_PAGE_INDETERMINATE.format(current=data.current, url=data.url), 

1056 indeterminate=True, 

1057 ) 

1058 elif event_type == EventType.CRAWL_PAGE_FAILED and isinstance( 

1059 data, CrawlPageFailedEvent 

1060 ): 

1061 failures.append(data.reason) 

1062 

1063 paths = asyncio_loop.run( 

1064 crawl_and_save( 

1065 url, 

1066 depth=depth, 

1067 max_pages=max_pages, 

1068 on_progress=on_progress, 

1069 quiet=True, 

1070 include_subdomains=include_subdomains, 

1071 render_mode=render_mode, 

1072 ) 

1073 ) 

1074 call_from_thread(self, self.notify, msg.CMD_CRAWL_SUCCESS.format(count=len(paths), url=url)) 

1075 if failures: 

1076 call_from_thread( 

1077 self, 

1078 self.notify, 

1079 msg.CMD_CRAWL_PAGES_FAILED.format(count=len(failures), reason=failures[-1]), 

1080 severity="warning", 

1081 ) 

1082 

1083 def _cmd_catalog(self, _args: str) -> None: 

1084 # switch_view already installs and navigates to the managed Catalog view; 

1085 # a push_screen on top would stack a second, orphaned CatalogScreen. 

1086 self.app.switch_view(msg.CATALOG_VIEW) 

1087 

1088 def _cmd_prune_ignored(self, args: str) -> None: 

1089 """Sync with pruning on, dropping indexed documents the patterns now exclude.""" 

1090 del args 

1091 self._run_sync(prune_ignored=True) 

1092 

1093 def _cmd_delete(self, args: str) -> None: 

1094 """Run /delete in a worker so the chat screen stays interactive.""" 

1095 self._cmd_delete_worker(args.strip()) 

1096 

1097 @work(thread=True, name="chat_cmd_delete", exit_on_error=False) 

1098 def _cmd_delete_worker(self, name: str) -> None: 

1099 """Validate and execute /delete off the UI thread; notify back via dispatch.""" 

1100 try: 

1101 sources = get_services().store.get_sources() 

1102 except Exception: 

1103 log.debug("Failed to list documents for /delete", exc_info=True) 

1104 call_from_thread(self, self.notify, msg.CMD_DELETE_READ_FAILED, severity="error") 

1105 return 

1106 

1107 known = {s.get("filename", s.get("source", "?")) for s in sources} 

1108 if not known: 

1109 call_from_thread(self, self.notify, msg.CMD_DELETE_NO_DOCS, severity="warning") 

1110 return 

1111 

1112 if not name: 

1113 usage = msg.CMD_DELETE_USAGE.format(names=", ".join(sorted(known))) 

1114 call_from_thread(self, self.notify, usage) 

1115 return 

1116 

1117 if name not in known: 

1118 message = msg.CMD_DELETE_NOT_FOUND.format(name=name) 

1119 suggestion = _closest_source(name, known) 

1120 if suggestion is not None: 

1121 message = f"{message}. {msg.CMD_DELETE_SUGGESTION.format(name=suggestion)}" 

1122 call_from_thread(self, self.notify, message, severity="error") 

1123 return 

1124 

1125 from lilbee.app.ingest import remove_documents_durably 

1126 from lilbee.cli.tui.widgets.autocomplete import invalidate_document_cache 

1127 

1128 # Skip-mark so the next sync doesn't re-ingest the kept file (durable, 

1129 # non-destructive delete; the file stays on disk). 

1130 remove_documents_durably([name]) 

1131 invalidate_document_cache() 

1132 call_from_thread(self, self.notify, msg.CMD_DELETE_SUCCESS.format(name=name)) 

1133 

1134 def _cmd_export(self, args: str) -> None: 

1135 """Enqueue /export as a task so progress shows in the task bar.""" 

1136 path = args.strip() 

1137 if not path: 

1138 self.notify(msg.CMD_EXPORT_USAGE, severity="warning") 

1139 return 

1140 from lilbee.cli.tui.task_queue import TaskType 

1141 

1142 def _target(reporter: ProgressReporter) -> None: 

1143 self._do_export(path, reporter) 

1144 

1145 name = msg.TASK_NAME_EXPORT.format(file=Path(path).name) 

1146 self._task_bar.start_task(name, TaskType.EXPORT, _target, indeterminate=True) 

1147 

1148 def _do_export(self, raw_path: str, reporter: ProgressReporter) -> None: 

1149 """Export body. Runs on the task worker thread.""" 

1150 from lilbee.app.dataset import DatasetError, export_to_path 

1151 

1152 output = Path(raw_path).expanduser() 

1153 reporter.update(0, msg.EXPORT_STATUS_RUNNING, indeterminate=True) 

1154 try: 

1155 summary = export_to_path(output, "", None) 

1156 except DatasetError as exc: 

1157 call_from_thread(self, self.notify, str(exc), severity="error") 

1158 raise RuntimeError(str(exc)) from exc 

1159 call_from_thread( 

1160 self, 

1161 self.notify, 

1162 msg.CMD_EXPORT_SUCCESS.format(pages=summary.pages, output=output), 

1163 ) 

1164 

1165 def _cmd_import(self, args: str) -> None: 

1166 """Enqueue /import as a task so re-embedding progress shows in the task bar.""" 

1167 path = args.strip() 

1168 if not path: 

1169 self.notify(msg.CMD_IMPORT_USAGE, severity="warning") 

1170 return 

1171 if self._sync_active: 

1172 self.notify(msg.SYNC_ALREADY_ACTIVE, severity="warning") 

1173 return 

1174 from lilbee.cli.tui.task_queue import TaskType 

1175 

1176 self._sync_active = True 

1177 

1178 def _target(reporter: ProgressReporter) -> None: 

1179 try: 

1180 self._do_import(path, reporter) 

1181 finally: 

1182 self._sync_active = False 

1183 self._task_bar.start_detect_pending() 

1184 

1185 name = msg.TASK_NAME_IMPORT.format(file=Path(path).name) 

1186 self._task_bar.start_task(name, TaskType.IMPORT, _target) 

1187 

1188 def _do_import(self, raw_path: str, reporter: ProgressReporter) -> None: 

1189 """Import body. Runs on the task worker thread.""" 

1190 from lilbee.app.dataset import DatasetError, import_from_path 

1191 from lilbee.cli.tui.widgets.autocomplete import invalidate_document_cache 

1192 

1193 reporter.update(0, msg.IMPORT_STATUS_LOADING, indeterminate=True) 

1194 try: 

1195 summary = asyncio_loop.run( 

1196 import_from_path( 

1197 Path(raw_path).expanduser(), 

1198 "", 

1199 on_progress=build_import_progress_callback(reporter), 

1200 ) 

1201 ) 

1202 except DatasetError as exc: 

1203 call_from_thread(self, self.notify, str(exc), severity="error") 

1204 raise RuntimeError(str(exc)) from exc 

1205 invalidate_document_cache() 

1206 call_from_thread( 

1207 self, 

1208 self.notify, 

1209 msg.CMD_IMPORT_SUCCESS.format( 

1210 sources=len(summary.sources), pages=summary.pages, chunks=summary.chunks 

1211 ), 

1212 ) 

1213 

1214 def _cmd_help(self, _args: str) -> None: 

1215 self.action_show_command_catalog() 

1216 

1217 def action_show_command_catalog(self) -> None: 

1218 """Push the slash-command catalog modal; selected name is inserted into the input.""" 

1219 self.app.push_screen(SlashCommandCatalog(), self._on_catalog_pick) 

1220 

1221 def insert_slash_command(self, name: str) -> None: 

1222 """Drop ``name + ' '`` into the chat input and focus it for argument entry.""" 

1223 self._enter_insert_mode() 

1224 inp = self._chat_input 

1225 inp.value = f"{name} " 

1226 inp.action_end() 

1227 

1228 def _on_catalog_pick(self, name: str | None) -> None: 

1229 if name is None: 

1230 return 

1231 self.insert_slash_command(name) 

1232 

1233 def _cmd_login(self, args: str) -> None: 

1234 token = args.strip() 

1235 if not token: 

1236 import webbrowser 

1237 

1238 webbrowser.open("https://huggingface.co/settings/tokens") 

1239 self.notify(msg.CHAT_LOGIN_PROMPT) 

1240 return 

1241 self._run_hf_login(token) 

1242 

1243 @work(thread=True) 

1244 def _run_hf_login(self, token: str) -> None: 

1245 try: 

1246 from huggingface_hub import login 

1247 

1248 login(token=token, add_to_git_credential=False) 

1249 call_from_thread(self, self.notify, msg.CHAT_LOGGED_IN) 

1250 except Exception as exc: 

1251 log.warning("HuggingFace login failed", exc_info=True) 

1252 call_from_thread( 

1253 self, self.notify, msg.CHAT_LOGIN_FAILED.format(error=exc), severity="error" 

1254 ) 

1255 

1256 def _cmd_model(self, args: str) -> None: 

1257 if args: 

1258 from lilbee.catalog.formatting import display_label_for_ref 

1259 

1260 apply_active_model(self.app, "chat_model", args) 

1261 self.app.title = msg.app_title(cfg.chat_model) 

1262 self.notify(msg.CMD_MODEL_SET.format(name=display_label_for_ref(cfg.chat_model))) 

1263 self.apply_model_change() 

1264 self.refresh_model_bar() 

1265 else: 

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

1267 

1268 self.app.push_screen(CatalogScreen()) 

1269 

1270 def _cmd_quit(self, _args: str) -> None: 

1271 self.app.exit() 

1272 

1273 def _cmd_remove(self, args: str) -> None: 

1274 name = args.strip() 

1275 if not name: 

1276 self.notify(msg.CMD_REMOVE_USAGE, severity="warning") 

1277 return 

1278 self._run_remove_model(name) 

1279 

1280 @work(thread=True) 

1281 def _run_remove_model(self, name: str) -> None: 

1282 mgr = get_services().model_manager 

1283 if not mgr.is_installed(name): 

1284 call_from_thread( 

1285 self, self.notify, msg.CMD_REMOVE_NOT_FOUND.format(name=name), severity="error" 

1286 ) 

1287 return 

1288 try: 

1289 removed = mgr.remove(name) 

1290 if removed: 

1291 call_from_thread(self, self.notify, msg.CMD_REMOVE_SUCCESS.format(name=name)) 

1292 else: 

1293 call_from_thread( 

1294 self, self.notify, msg.CMD_REMOVE_FAILED.format(name=name), severity="error" 

1295 ) 

1296 except Exception: 

1297 log.warning("Remove failed for %s", name, exc_info=True) 

1298 call_from_thread( 

1299 self, self.notify, msg.CMD_REMOVE_FAILED.format(name=name), severity="error" 

1300 ) 

1301 

1302 def _cmd_rebuild(self, _args: str) -> None: 

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

1304 

1305 def _on_confirm(confirmed: bool | None) -> None: 

1306 if not confirmed: 

1307 return 

1308 self._run_sync(force_rebuild=True) 

1309 

1310 self.app.push_screen( 

1311 ConfirmDialog(msg.CMD_REBUILD_CONFIRM_TITLE, msg.CMD_REBUILD_CONFIRM_MESSAGE), 

1312 _on_confirm, 

1313 ) 

1314 

1315 def _cmd_reset(self, args: str) -> None: 

1316 self.request_reset() 

1317 

1318 def request_reset(self) -> None: 

1319 """Public entry for the confirm-then-wipe flow (shared by /reset and the 

1320 command palette), so callers don't reach into a private slash handler.""" 

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

1322 

1323 def _on_confirm(confirmed: bool | None) -> None: 

1324 if not confirmed: 

1325 return 

1326 from lilbee.app.reset import perform_reset 

1327 

1328 try: 

1329 result = perform_reset() 

1330 except Exception as exc: 

1331 log.warning("Reset failed", exc_info=True) 

1332 self.notify(msg.CMD_RESET_FAILED.format(error=exc), severity="error") 

1333 return 

1334 

1335 # Reopen LanceDB against the now-empty data dir; keep providers loaded. 

1336 reset_store() 

1337 

1338 if result.skipped: 

1339 self.notify( 

1340 msg.CMD_RESET_PARTIAL.format(skipped=len(result.skipped)), 

1341 severity="warning", 

1342 ) 

1343 else: 

1344 self.notify(msg.CMD_RESET_SUCCESS) 

1345 

1346 self.app.push_screen( 

1347 ConfirmDialog(msg.CMD_RESET_CONFIRM_TITLE, msg.CMD_RESET_CONFIRM_MESSAGE), 

1348 _on_confirm, 

1349 ) 

1350 

1351 def _cmd_set(self, args: str) -> None: 

1352 if not args: 

1353 return 

1354 parts = args.split(None, 1) 

1355 key = parts[0] 

1356 value = parts[1] if len(parts) > 1 else "" 

1357 

1358 if key not in SETTINGS_MAP: 

1359 self.notify(msg.CMD_SET_UNKNOWN.format(key=key), severity="warning") 

1360 return 

1361 

1362 defn = SETTINGS_MAP[key] 

1363 if not defn.writable: 

1364 self.notify(msg.CMD_SET_READONLY.format(key=key), severity="warning") 

1365 return 

1366 try: 

1367 if defn.type is bool: 

1368 parsed = value.lower() in ("true", "1", "yes", "on") 

1369 elif defn.nullable and value.lower() in ("none", "null", ""): 

1370 parsed = None 

1371 else: 

1372 if defn.choices and value not in defn.choices: 

1373 self.notify( 

1374 msg.CMD_SET_CHOICES.format(key=key, choices=", ".join(defn.choices)), 

1375 severity="error", 

1376 ) 

1377 return 

1378 try: 

1379 parsed = defn.type(value) 

1380 except (ValueError, TypeError): 

1381 self.notify( 

1382 msg.CMD_SET_TYPE_HINT.format(key=key, kind=_setting_type_hint(defn.type)), 

1383 severity="error", 

1384 ) 

1385 return 

1386 # Route through set_setting so settings_changed_signal subscribers 

1387 # (model bar, scope chip, status bar) refresh. The boundary's 

1388 # _invalidate_caches now handles llm_provider service reset. 

1389 self.app.set_setting(key, parsed) 

1390 shown = msg.MASKED_VALUE if defn.secret and parsed else parsed 

1391 self.notify(msg.CMD_SET_SUCCESS.format(key=key, value=shown)) 

1392 except (ValueError, TypeError) as exc: 

1393 self.notify(msg.CMD_SET_INVALID.format(key=key, error=exc), severity="error") 

1394 

1395 def _cmd_settings(self, _args: str) -> None: 

1396 self.app.switch_view("Settings") 

1397 

1398 def _cmd_remember(self, args: str) -> None: 

1399 """Run /remember in a worker so embedding the text never blocks the UI.""" 

1400 self._cmd_remember_worker(args) 

1401 

1402 @work(thread=True, name="chat_cmd_remember", exit_on_error=False) 

1403 def _cmd_remember_worker(self, raw: str) -> None: 

1404 """Store the memory off the UI thread; notify the outcome back on it.""" 

1405 outcome = remember_from_input(raw) 

1406 call_from_thread(self, self.notify, outcome.message, severity=outcome.severity) 

1407 

1408 def _cmd_memories(self, _args: str) -> None: 

1409 from lilbee.cli.tui.screens.memories import MemoriesScreen 

1410 

1411 self.app.push_screen(MemoriesScreen()) 

1412 

1413 def _cmd_status(self, _args: str) -> None: 

1414 self.app.switch_view("Status") 

1415 

1416 def _cmd_theme(self, args: str) -> None: 

1417 if not args: 

1418 # Land in the prompt with the dropdown listing every theme. 

1419 self.insert_slash_command("/theme") 

1420 return 

1421 if args not in DARK_THEMES: 

1422 self.notify( 

1423 msg.CMD_THEME_UNKNOWN.format(name=args, names=", ".join(DARK_THEMES)), 

1424 severity="warning", 

1425 ) 

1426 return 

1427 self.app.set_theme(args) 

1428 self.notify(msg.THEME_SET.format(name=args)) 

1429 

1430 def _cmd_version(self, _args: str) -> None: 

1431 self.notify(msg.CHAT_VERSION.format(version=get_version())) 

1432 

1433 def _cmd_wiki(self, _args: str) -> None: 

1434 if not cfg.wiki: 

1435 self.notify(msg.CMD_WIKI_DISABLED, severity="warning") 

1436 return 

1437 self.app.switch_view("Wiki") 

1438 

1439 def _cmd_sessions(self, _args: str) -> None: 

1440 self.app.action_toggle_sessions() 

1441 

1442 def _send_message(self, text: str) -> None: 

1443 """Send a user message and stream the response.""" 

1444 from textual.css.query import NoMatches 

1445 

1446 log = self._chat_log 

1447 with contextlib.suppress(NoMatches): 

1448 log.query_one("#chat-welcome", ChatWelcome).remove() 

1449 question = UserMessage(text) 

1450 log.mount(question) 

1451 self._active_question = question 

1452 

1453 # The assistant bubble owns its own ThinkingHeader animator until 

1454 # the first reasoning or content token swaps it out. 

1455 assistant_msg = AssistantMessage() 

1456 self._active_assistant = assistant_msg 

1457 log.mount(assistant_msg) 

1458 # A fresh turn always follows its own answer, even if the user had 

1459 # scrolled up during the previous response and released the anchor. 

1460 log.anchor() 

1461 

1462 with self._history_lock: 

1463 self._history.append({"role": "user", "content": text}) 

1464 self._persist_user_turn(text) 

1465 self.streaming = True 

1466 self._stream_response(text, assistant_msg, self._current_chunk_type()) 

1467 

1468 def _current_scope_value(self) -> str: 

1469 """The ScopeChip's selection, or "both" when the chip isn't mounted.""" 

1470 from textual.css.query import NoMatches 

1471 

1472 from lilbee.cli.tui.widgets.scope_chip import ScopeChip 

1473 

1474 try: 

1475 chip = self.query_one("#scope-chip", ScopeChip) 

1476 except NoMatches: 

1477 return SearchScope.BOTH.value 

1478 return chip.scope 

1479 

1480 def _current_chunk_type(self) -> ChunkType | None: 

1481 """Translate the ScopeChip selection into a ``chunk_type`` arg. 

1482 

1483 Returns ``None`` for "both" (no filter) and the raw/wiki ``ChunkType`` 

1484 otherwise. 

1485 """ 

1486 return scope_to_chunk_type(self._current_scope_value()) 

1487 

1488 def _open_session(self, store: SessionStore, first_text: str) -> str: 

1489 """Create the active session, auto-title it, and return its id.""" 

1490 session_id = store.create(model_ref=cfg.chat_model, scope=self._current_scope_value()) 

1491 store.set_title(session_id, derive_title(first_text), TitleSource.AUTO) 

1492 self._session_id = session_id 

1493 return session_id 

1494 

1495 def _persist_user_turn(self, text: str) -> None: 

1496 """Open a session on the first turn (auto-titled), then append the message.""" 

1497 if not cfg.sessions_enabled: 

1498 # Sessions turned off: the conversation stays live in memory but is 

1499 # never written to disk. _session_id stays None, so the assistant 

1500 # turn's persist is a no-op too. 

1501 return 

1502 store = get_services().session_store 

1503 session_id = self._session_id or self._open_session(store, text) 

1504 message = SessionMessage(role=MessageRole.USER, content=text) 

1505 try: 

1506 store.add_message(session_id, message, surface=SessionOrigin.TUI) 

1507 except SessionNotFoundError: 

1508 # The active session was deleted mid-chat (e.g. from the drawer); 

1509 # open a fresh one so auto-save keeps working instead of crashing. 

1510 store.add_message(self._open_session(store, text), message, surface=SessionOrigin.TUI) 

1511 

1512 def _persist_assistant_turn(self, content: str, sources: list[str]) -> None: 

1513 """Append the assistant turn to the active session. Worker thread.""" 

1514 if self._session_id is None or not cfg.sessions_enabled: 

1515 # Sessions switched off mid-conversation: the id outlives the 

1516 # setting, so the toggle has to be re-checked here rather than 

1517 # relying on _persist_user_turn having left the id unset. 

1518 return 

1519 # A concurrent delete of the active session must not crash the worker. 

1520 with contextlib.suppress(SessionNotFoundError): 

1521 get_services().session_store.add_message( 

1522 self._session_id, 

1523 SessionMessage(role=MessageRole.ASSISTANT, content=content, sources=tuple(sources)), 

1524 surface=SessionOrigin.TUI, 

1525 ) 

1526 

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

1528 """Load a saved session into the chat view and make it the active one.""" 

1529 store = get_services().session_store 

1530 session = store.get(session_id) 

1531 self._reset_conversation() 

1532 self._session_id = session_id 

1533 for message in session.messages: 

1534 self._render_restored_message(message) 

1535 # Load the whole transcript and the summary it was compacted with. What 

1536 # does not fit is folded into the summary by _compact_history on the next 

1537 # turn, off the UI thread; windowing it away here would silently lose the 

1538 # turns between the stored summary and the window, which is precisely 

1539 # what a resumed conversation must not do. 

1540 loaded: list[ChatMessage] = [ 

1541 {"role": message.role.value, "content": message.content} for message in session.messages 

1542 ] 

1543 with self._history_lock: 

1544 self._history = loaded 

1545 self._summary = session.summary 

1546 self._restore_session_model(session.meta.model_ref) 

1547 self._refresh_context_usage() 

1548 self._chat_log.scroll_end(animate=False) 

1549 self.notify(msg.SESSIONS_RESUMED.format(title=session.meta.title)) 

1550 

1551 def _restore_session_model(self, model_ref: str) -> None: 

1552 """Switch to the session's chat model if it is still installed. 

1553 

1554 A conversation records the model it used, but that model may have been 

1555 deleted since. Restoring a missing ref would be rejected by the model 

1556 boundary with a scary error, so only switch when the model is installed; 

1557 otherwise keep the current model and say the original is gone. 

1558 """ 

1559 if not model_ref or model_ref == cfg.chat_model: 

1560 return 

1561 if get_services().registry.is_installed(model_ref): 

1562 apply_active_model(self.app, "chat_model", model_ref) 

1563 else: 

1564 self.notify( 

1565 msg.SESSIONS_MODEL_UNAVAILABLE.format(model=model_ref, current=cfg.chat_model), 

1566 severity="warning", 

1567 ) 

1568 

1569 @property 

1570 def session_id(self) -> str | None: 

1571 """The saved session this conversation persists to, or None before the first turn.""" 

1572 return self._session_id 

1573 

1574 def start_new_conversation(self) -> None: 

1575 """Clear the conversation and open a fresh session on the next turn.""" 

1576 self._reset_conversation() 

1577 self.notify(msg.SESSIONS_NEW) 

1578 

1579 def _render_restored_message(self, message: SessionMessage) -> None: 

1580 """Mount a completed message widget for a resumed turn.""" 

1581 log = self._chat_log 

1582 if message.role == MessageRole.USER: 

1583 log.mount(UserMessage(message.content)) 

1584 return 

1585 # Constructed complete, not appended-to after mounting: mount() is async, 

1586 # so append_content/finish would both no-op against a content widget that 

1587 # compose has not built yet, and the answer would render empty. 

1588 log.mount(AssistantMessage(content=message.content, sources=list(message.sources))) 

1589 

1590 @work(thread=True) 

1591 def _stream_response( 

1592 self, question: str, widget: AssistantMessage, chunk_type: ChunkType | None 

1593 ) -> None: 

1594 """Schedule the response stream on a background thread.""" 

1595 self._do_stream_response(question, widget, chunk_type) 

1596 

1597 def _do_stream_response( 

1598 self, question: str, widget: AssistantMessage, chunk_type: ChunkType | None 

1599 ) -> None: 

1600 """Stream LLM response, coalescing UI updates. Worker thread.""" 

1601 response_parts: list[str] = [] 

1602 sources: list[str] = [] 

1603 stream: Any = None 

1604 try: 

1605 if not self._await_chat_engine(widget): 

1606 return 

1607 self._compact_history() 

1608 with self._history_lock: 

1609 # [:-1] drops the question, which ask_stream takes separately. 

1610 recent = self._history[:-1] 

1611 summary = self._summary 

1612 history_snapshot = prompt_history(recent, summary, max_tokens=self._history_budget()) 

1613 stream = get_services().searcher.ask_stream( 

1614 question, history=history_snapshot, chunk_type=chunk_type 

1615 ) 

1616 self._consume_stream(stream, widget, response_parts) 

1617 except EmbeddingModelMismatchError as exc: 

1618 with contextlib.suppress(Exception): 

1619 call_from_thread(self, self._on_embedding_mismatch, exc, question, widget) 

1620 except Exception as exc: 

1621 log.debug("Stream error", exc_info=True) 

1622 # A deliberate cancel severs the transport, which surfaces here as a 

1623 # stream error; the cancel already wrote its note into the bubble. 

1624 if not self._stream_worker_cancelled(): 

1625 # A severed engine socket names the OS error, not the problem. 

1626 error_text = ( 

1627 msg.STREAM_DISCONNECTED 

1628 if isinstance(exc, ConnectionError) 

1629 else msg.STREAM_ERROR.format(error=exc) 

1630 ) 

1631 with contextlib.suppress(Exception): 

1632 call_from_thread(self, widget.append_content, error_text) 

1633 finally: 

1634 close_stream(stream) 

1635 self._finalize_stream(widget, sources, response_parts) 

1636 call_from_thread(self, self._maybe_extract_memories, question, "".join(response_parts)) 

1637 

1638 @staticmethod 

1639 def _stream_worker_cancelled() -> bool: 

1640 """Whether the calling stream worker was cancelled; False off-worker.""" 

1641 try: 

1642 return _get_worker().is_cancelled 

1643 except NoActiveWorker: 

1644 return False 

1645 

1646 def _await_chat_engine(self, widget: AssistantMessage) -> bool: 

1647 """Hold the stream until the engine can serve, painting the load into *widget*. 

1648 

1649 The default lifecycle loads the engine on demand, so the first prompt of 

1650 a session usually lands here: the answer bubble's thinking row carries the 

1651 live load phase instead of the input locking up. Worker thread. Returns 

1652 False once the wait was cancelled or the load failed, with any failure 

1653 already rendered into the bubble. 

1654 """ 

1655 from lilbee.app.placement import ( 

1656 chat_engine_ready, 

1657 chat_warm_error, 

1658 request_engine_warm, 

1659 wait_chat_ready, 

1660 ) 

1661 

1662 # Build the container if nothing holds it (a settings change resets it); 

1663 # readiness is probed via peek_services, which never builds, so without 

1664 # this a prompt sent into the gap would report a dead engine instead of 

1665 # lazily rebuilding the way ask_stream always has. 

1666 get_services() 

1667 if chat_engine_ready(): 

1668 return True 

1669 # A failed boot warm leaves nothing in flight; this restarts the engine 

1670 # so the prompt waits out a fresh load instead of bouncing. 

1671 request_engine_warm() 

1672 self._show_warm_tip_once() 

1673 worker = _get_worker() 

1674 

1675 def _paint(snapshot: WarmProgress) -> None: 

1676 with contextlib.suppress(Exception): 

1677 call_from_thread(self, widget.set_thinking_status, _engine_status_text(snapshot)) 

1678 

1679 # Label the wait before the chat warm stamps its first phase: another 

1680 # role loading first (embed on a cold start) leaves the tracker silent 

1681 # for many seconds, and a bare scanner reads as a hang. 

1682 with contextlib.suppress(Exception): 

1683 call_from_thread(self, widget.set_thinking_status, msg.ENGINE_WARMING) 

1684 if wait_chat_ready(on_progress=_paint, should_abort=lambda: worker.is_cancelled): 

1685 with contextlib.suppress(Exception): 

1686 call_from_thread(self, widget.set_thinking_status, "") 

1687 return True 

1688 if worker.is_cancelled: 

1689 return False 

1690 error = chat_warm_error() 

1691 text = ( 

1692 f"{msg.ENGINE_LOAD_FAILED.format(error=error)}\n{msg.ENGINE_FAILED_HINT}" 

1693 if error is not None 

1694 else msg.ENGINE_NOT_READY 

1695 ) 

1696 with contextlib.suppress(Exception): 

1697 call_from_thread(self, widget.append_content, text) 

1698 return False 

1699 

1700 def _show_warm_tip_once(self) -> None: 

1701 """Toast the keep-warm tip on the session's first cold-engine wait. Worker thread.""" 

1702 if cfg.keep_engine_warm or self._warm_tip_shown: 

1703 return 

1704 self._warm_tip_shown = True 

1705 with contextlib.suppress(Exception): 

1706 call_from_thread(self, self.notify, msg.ENGINE_WARM_TIP, timeout=8) 

1707 

1708 def _maybe_extract_memories(self, question: str, answer: str) -> None: 

1709 """Spawn auto-extraction for the finished turn, when enabled and idle. 

1710 

1711 Runs on the main thread (scheduled from the stream worker). Skips while 

1712 indexing so the extraction's embed call never contends with a sync. 

1713 """ 

1714 from lilbee.app.memory import auto_extract_enabled 

1715 

1716 if not answer or not auto_extract_enabled() or self._indexing_active(): 

1717 return 

1718 self._extract_memories_worker(question, answer) 

1719 

1720 def _indexing_active(self) -> bool: 

1721 """True while a sync/add/import/wiki task is running (embed worker is busy). 

1722 

1723 Wiki counts: a build embeds citations and a draft accept re-chunks and 

1724 re-indexes the page it publishes. 

1725 """ 

1726 from lilbee.cli.tui.task_queue import TaskType 

1727 

1728 busy = { 

1729 TaskType.SYNC.value, 

1730 TaskType.ADD.value, 

1731 TaskType.IMPORT.value, 

1732 TaskType.WIKI.value, 

1733 } 

1734 return any(task.task_type in busy for task in self._task_bar.queue.active_tasks) 

1735 

1736 @work(thread=True, name="chat_memory_extract", exit_on_error=False) 

1737 def _extract_memories_worker(self, question: str, answer: str) -> None: 

1738 """Extract durable memories off the UI thread; notify how many landed.""" 

1739 from lilbee.app.memory import auto_extract 

1740 

1741 stored = auto_extract(question, answer) 

1742 if stored: 

1743 call_from_thread(self, self.notify, msg.MEMORY_AUTO_EXTRACTED.format(count=len(stored))) 

1744 

1745 def _on_embedding_mismatch( 

1746 self, exc: EmbeddingModelMismatchError, question: str, widget: AssistantMessage 

1747 ) -> None: 

1748 """Offer to adopt the index's embedder (same dim) or explain the rebuild path.""" 

1749 if not exc.dims_match: 

1750 widget.append_content(msg.EMBED_ADOPT_REBUILD_NOTICE.format(dim=exc.persisted_dim)) 

1751 return 

1752 widget.append_content(msg.EMBED_ADOPT_NOTICE.format(model=exc.persisted_model)) 

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

1754 

1755 self.app.push_screen( 

1756 ConfirmDialog( 

1757 msg.EMBED_ADOPT_CONFIRM_TITLE, 

1758 msg.EMBED_ADOPT_CONFIRM_MESSAGE.format(model=exc.persisted_model), 

1759 ), 

1760 lambda ok: self._on_adopt_confirm(ok, exc.persisted_model, question), 

1761 ) 

1762 

1763 def _on_adopt_confirm(self, confirmed: bool | None, ref: str, question: str) -> None: 

1764 """Run the adopt+retry in a worker thread, or report the cancellation.""" 

1765 if not confirmed: 

1766 self.notify(msg.EMBED_ADOPT_CANCELLED) 

1767 return 

1768 self.notify(msg.EMBED_ADOPTING.format(model=ref)) 

1769 self._adopt_and_retry(ref, question) 

1770 

1771 @work(thread=True) 

1772 def _adopt_and_retry(self, ref: str, question: str) -> None: 

1773 """Schedule the adopt+retry on a worker thread (pull may be slow).""" 

1774 self._do_adopt_and_retry(ref, question) 

1775 

1776 def _do_adopt_and_retry(self, ref: str, question: str) -> None: 

1777 """Switch to embedder *ref* (downloading if needed), then re-ask. Worker thread.""" 

1778 from lilbee.app.models import adopt_embedder 

1779 

1780 try: 

1781 adopt_embedder(ref) 

1782 except Exception as exc: # surfaced to the user, never silently swallowed 

1783 log.debug("Embedder adopt failed", exc_info=True) 

1784 call_from_thread( 

1785 self, self.notify, msg.EMBED_ADOPT_FAILED.format(error=exc), severity="error" 

1786 ) 

1787 return 

1788 call_from_thread(self, self.notify, msg.EMBED_ADOPTED.format(model=ref)) 

1789 call_from_thread(self, self._send_message, question) 

1790 

1791 def _consume_stream( 

1792 self, stream: Any, widget: AssistantMessage, response_parts: list[str] 

1793 ) -> None: 

1794 """Pull tokens off *stream*, batching UI updates to ~50 ms windows.""" 

1795 worker = _get_worker() 

1796 reason_buf: list[str] = [] 

1797 content_buf: list[str] = [] 

1798 timings = _StreamTimings(last_flush=time.monotonic()) 

1799 

1800 def flush() -> None: 

1801 if reason_buf: 

1802 call_from_thread(self, widget.append_reasoning, "".join(reason_buf)) 

1803 reason_buf.clear() 

1804 if content_buf: 

1805 call_from_thread(self, widget.append_content, "".join(content_buf)) 

1806 content_buf.clear() 

1807 

1808 for token in stream: 

1809 if worker.is_cancelled: 

1810 break 

1811 try: 

1812 if isinstance(token, RetrievalNotice): 

1813 status = msg.SEARCHING_FOR.format(query=token.query) 

1814 call_from_thread(self, widget.set_thinking_status, status) 

1815 continue 

1816 self._buffer_token(token, reason_buf, content_buf, response_parts) 

1817 self._maybe_flush(flush, timings) 

1818 except Exception: 

1819 break # App shutting down (Ctrl-C) -- stop streaming 

1820 with contextlib.suppress(Exception): 

1821 flush() 

1822 

1823 @staticmethod 

1824 def _buffer_token( 

1825 token: Any, 

1826 reason_buf: list[str], 

1827 content_buf: list[str], 

1828 response_parts: list[str], 

1829 ) -> None: 

1830 """Append *token* to the right buffer; record response content for history.""" 

1831 if token.is_reasoning: 

1832 reason_buf.append(token.content) 

1833 elif token.content: 

1834 response_parts.append(token.content) 

1835 content_buf.append(token.content) 

1836 

1837 def _maybe_flush(self, flush: Callable[[], None], timings: _StreamTimings) -> None: 

1838 """Run *flush* on its interval. The chat log is anchored, so Textual 

1839 keeps the answer's tail in view as it grows without a scroll of ours. 

1840 """ 

1841 now = time.monotonic() 

1842 if now - timings.last_flush >= _STREAM_FLUSH_INTERVAL: 

1843 flush() 

1844 timings.last_flush = now 

1845 

1846 def _finalize_stream( 

1847 self, widget: AssistantMessage, sources: list[str], response_parts: list[str] 

1848 ) -> None: 

1849 """Persist the assistant turn and update the widget. Always runs.""" 

1850 # _stream_response runs in a worker thread; reactive setters mutate 

1851 # widgets, so the streaming flag must flip on the main thread. 

1852 call_from_thread(self, self._set_streaming, False) 

1853 full_response = "".join(response_parts) 

1854 if full_response: 

1855 with self._history_lock: 

1856 self._history.append({"role": "assistant", "content": full_response}) 

1857 # No trim here: the next turn compacts before it builds its prompt, so 

1858 # trimming now would drop turns without folding them into the summary. 

1859 self._persist_assistant_turn(full_response, sources) 

1860 call_from_thread(self, self._refresh_context_usage) 

1861 call_from_thread(self, widget.finish, sources) 

1862 if ( 

1863 cfg.chat_mode == ChatMode.SEARCH.value 

1864 and self._embedding_ready() 

1865 and full_response 

1866 and SOURCES_BLOCK_MARKER not in full_response 

1867 ): 

1868 call_from_thread(self, self._notify_no_results) 

1869 

1870 def _notify_no_results(self) -> None: 

1871 self.notify(msg.CHAT_MODE_SEARCH_NO_RESULTS, severity="warning") 

1872 

1873 @staticmethod 

1874 def _history_budget() -> int: 

1875 """Token budget for everything this conversation carries into the prompt.""" 

1876 return history_budget(cfg.chat_n_ctx_target) 

1877 

1878 def _compact_history(self) -> None: 

1879 """Fold turns that no longer fit into the rolling summary. Worker thread only. 

1880 

1881 Runs before a prompt is built rather than after a turn lands, so the 

1882 summary is always current with what is about to be sent, and a resumed 

1883 conversation compacts what it cannot carry instead of dropping it. 

1884 

1885 The summarizing model call is slow, so it happens without the lock held; 

1886 only the known prefix is removed afterwards, which stays correct if the 

1887 user sends another turn meanwhile. 

1888 """ 

1889 with self._history_lock: 

1890 history = list(self._history) 

1891 summary = self._summary 

1892 budget = self._history_budget() 

1893 if not cfg.chat_compaction: 

1894 # Default path, deliberately free: prune exactly to the limit, no 

1895 # model call. The summary is charged against the same budget so a 

1896 # session compacted on earlier hardware still carries its notes. 

1897 reserved = sum(estimate_tokens(m) for m in summary_messages(summary)) 

1898 dropped = overflow(history, max_tokens=max(1, budget - reserved)) 

1899 if not dropped: 

1900 return 

1901 with self._history_lock: 

1902 del self._history[: len(dropped)] 

1903 call_from_thread(self, self._on_history_trimmed, len(dropped)) 

1904 return 

1905 # Compaction on: fire early, clear deep (see COMPACT_TRIGGER_FRACTION). 

1906 if not compaction_due(history, summary, max_tokens=budget): 

1907 return 

1908 dropped = foldable(history) 

1909 if not dropped: 

1910 # Nothing but the tail, and it alone fills the budget. Folding it 

1911 # would summarize the very turn being answered; prompt_history windows 

1912 # it instead. 

1913 return 

1914 # Condensing blocks this turn on a model call: seconds on a GPU, tens of 

1915 # seconds on a CPU-only host. An unannounced pause that long is 

1916 # indistinguishable from a hang, so say what is happening first. 

1917 call_from_thread(self, self._set_compacting, True) 

1918 try: 

1919 result = get_services().searcher.summarize_history(dropped, summary) 

1920 finally: 

1921 call_from_thread(self, self._set_compacting, False) 

1922 with self._history_lock: 

1923 del self._history[: len(dropped)] 

1924 self._summary = result.summary 

1925 if self._session_id and result.summary and cfg.sessions_enabled: 

1926 # A summary for a session deleted mid-chat is not worth a crash; the 

1927 # next user turn reopens one and re-summarizes from there. The 

1928 # toggle is re-checked because the fold keeps working in memory 

1929 # after sessions go off, but must not reach the disk. 

1930 with contextlib.suppress(SessionNotFoundError): 

1931 get_services().session_store.set_summary(self._session_id, result.summary) 

1932 call_from_thread(self, self._on_history_compacted, result.condensed, result.stranded) 

1933 

1934 def _set_compacting(self, compacting: bool) -> None: 

1935 """Flip the chip into (or out of) its condensing state. Main thread only.""" 

1936 with contextlib.suppress(NoMatches): 

1937 self.query_one("#context-chip", ContextChip).compacting = compacting 

1938 

1939 def _refresh_context_usage(self) -> None: 

1940 """Push current history pressure to the chip. Main thread only. 

1941 

1942 Cheap: the same char/4 estimate the windower already uses, over messages 

1943 that are in memory anyway. Recomputed per turn rather than per keystroke. 

1944 """ 

1945 with self._history_lock: 

1946 history = list(self._history) 

1947 summary = self._summary 

1948 budget = self._history_budget() 

1949 used = sum(estimate_tokens(m) for m in history) 

1950 used += sum(estimate_tokens(m) for m in summary_messages(summary)) 

1951 with contextlib.suppress(NoMatches): 

1952 self.query_one("#context-chip", ContextChip).usage = used / max(1, budget) 

1953 

1954 def _mark_context_boundary(self, *titles: str) -> None: 

1955 """Draw rules in the log where the model's view of the chat changed. 

1956 

1957 A rich Rule, not a hand-drawn "-- text --": it draws the line out to the 

1958 full width itself, which is what makes it read as a boundary rather than 

1959 as another message. Guarded because the worker can land this after the 

1960 user has navigated off the chat screen. 

1961 """ 

1962 # mount() is async: the anchor may not be in the log yet, and mounting 

1963 # before a non-child raises. Appending reads fine in that race. 

1964 anchor = self._active_question 

1965 if anchor is not None and not anchor.is_mounted: 

1966 anchor = None 

1967 with contextlib.suppress(NoMatches): 

1968 for title in titles: 

1969 rule = Static( 

1970 Rule(title=title, characters="─", style="dim"), 

1971 classes="compaction-marker", 

1972 ) 

1973 if anchor is None: 

1974 self._chat_log.mount(rule) 

1975 else: 

1976 self._chat_log.mount(rule, before=anchor) 

1977 

1978 def _on_history_trimmed(self, dropped: int) -> None: 

1979 """Mark where turns left the model's view with nothing standing in for them. 

1980 

1981 The compaction-off path. Same rule as compaction so the log reads 

1982 consistently, different words because nothing was summarized. 

1983 """ 

1984 self._refresh_context_usage() 

1985 self._mark_context_boundary(msg.CHAT_TRIMMED.format(count=dropped)) 

1986 with contextlib.suppress(NoMatches): 

1987 self.notify(msg.CHAT_TRIMMED_TOAST, severity="warning") 

1988 

1989 def _on_history_compacted(self, condensed: int, stranded: int) -> None: 

1990 """Mark where the model's memory of this conversation turns into a summary. 

1991 

1992 Styling lives in chat.tcss under .compaction-marker. Guarded because the 

1993 worker can land this after the user navigated off the chat screen. 

1994 

1995 Stranded turns get their own line: they are gone from the model's view 

1996 with nothing standing in for them, and a user whose model has forgotten 

1997 something is owed the reason rather than left to infer it. 

1998 """ 

1999 self._refresh_context_usage() 

2000 titles = [msg.CHAT_COMPACTED.format(count=condensed)] 

2001 if stranded: 

2002 titles.append(msg.CHAT_COMPACTION_STRANDED.format(count=stranded)) 

2003 self._mark_context_boundary(*titles) 

2004 with contextlib.suppress(NoMatches): 

2005 self.notify( 

2006 msg.CHAT_COMPACTED_STRANDED_TOAST if stranded else msg.CHAT_COMPACTED_TOAST, 

2007 severity="warning", 

2008 ) 

2009 

2010 def action_scroll_up(self) -> None: 

2011 self._chat_log.scroll_page_up() 

2012 

2013 def action_scroll_down(self) -> None: 

2014 self._chat_log.scroll_page_down() 

2015 

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

2017 """Keep the footer honest about mode-dependent bindings. 

2018 

2019 - ``cancel_stream`` (Ctrl+C) only does something while streaming in 

2020 INSERT mode; otherwise the App's Quit binding takes the slot. 

2021 """ 

2022 if action == "cancel_stream": 

2023 return self.streaming and self._insert_mode 

2024 if action == "enter_model_strip": 

2025 # NORMAL mode parks the cursor on the transcript, and that is the 

2026 # only place these letters are free. Stated as where they DO apply, 

2027 # so a drawer, a dialog or any later focus target keeps its own 

2028 # letters without having to be named here. 

2029 focused = self.focused 

2030 return focused is not None and focused.id == "chat-log" 

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

2032 

2033 def action_enter_normal_mode(self) -> None: 

2034 """Esc dismisses the overlay if visible; otherwise drops into NORMAL mode.""" 

2035 overlay = self._completion_overlay 

2036 if overlay.is_visible: 

2037 # Revert any previewed candidate back to what the user typed. 

2038 if self._completion_origin is not None and self._chat_input.value != ( 

2039 self._completion_origin 

2040 ): 

2041 self._set_input(self._completion_origin) 

2042 self._completion_origin = None 

2043 overlay.hide() 

2044 # Backing out of the command list leaves nothing worth keeping in 

2045 # a lone slash, and it would hijack the next message as /word. 

2046 if self._chat_input.value.strip() == "/": 

2047 self._set_input("") 

2048 return 

2049 if isinstance(self.focused, Select) or self._focus_in_model_bar(): 

2050 # Leaving the model strip should put us back in INSERT so the 

2051 # user can type their next prompt; routing through the helper 

2052 # makes sure can_focus is re-enabled. 

2053 self._enter_insert_mode() 

2054 return 

2055 self._insert_mode = False 

2056 # Make the chat input unfocusable in NORMAL mode so Tab traversal 

2057 # skips past it AND a programmatic focus restore (modal close, 

2058 # screen pop) cannot land on it. The user re-enters INSERT 

2059 # explicitly via i/a/o/Enter or by clicking the input. 

2060 self._chat_input.can_focus = False 

2061 self._chat_log.focus() 

2062 self._update_input_style() 

2063 

2064 def action_cancel_stream(self) -> None: 

2065 """Cancel an in-flight chat stream. Bound to Ctrl+C from INSERT mode.""" 

2066 if self.streaming: 

2067 self._cancel_inflight_stream(msg.STREAM_CANCELLED) 

2068 

2069 def _cancel_inflight_stream(self, note: str) -> None: 

2070 """Stop the streaming worker, sever its inference call, and say so. 

2071 

2072 The worker cancel is cooperative and only observed between tokens, so 

2073 ``cancel_inference`` severs the in-flight stream's transport to unblock 

2074 a reader stuck in a socket read. *note* lands in the answer bubble: a 

2075 cancelled turn must say it was cancelled, not die silently while the 

2076 user waits for an answer that will never arrive. 

2077 """ 

2078 for worker in self.workers: 

2079 worker.cancel() 

2080 get_services().cancel_inference() 

2081 bubble = self._active_assistant 

2082 if bubble is not None and bubble.is_mounted: 

2083 bubble.append_content(note) 

2084 self.streaming = False 

2085 

2086 def apply_model_change(self) -> None: 

2087 """Swap to the new chat model without freezing the UI or losing an answer. 

2088 

2089 Reloading the fleet for the new model is a multi-second restart, so it 

2090 runs in a thread worker instead of on the event loop. The worker reloads 

2091 only the chat role; the provider retires any still-busy client across the 

2092 restart and serializes overlapping reloads, so the worker can start at 

2093 once without waiting for other workers. 

2094 

2095 A switch requested mid-answer is queued, not applied: restarting the chat 

2096 server under a live stream kills the answer being read. The queued switch 

2097 runs on leaving the streaming state, so it covers a finished, cancelled 

2098 and cleared answer alike. 

2099 """ 

2100 if self.swapping_model: 

2101 # A swap is already loading; a second one (rapid /model, or the model 

2102 # bar re-clicked while the input is disabled) would spawn a duplicate 

2103 # worker and a duplicate completion toast. The in-flight reload already 

2104 # coalesces onto the latest cfg, so ignore the re-entry. 

2105 self.notify(msg.CHAT_MODEL_SWITCHING, severity="warning", timeout=3) 

2106 return 

2107 if self.streaming: 

2108 from lilbee.catalog.formatting import display_label_for_ref 

2109 

2110 # cfg already holds the new ref, so a second queued switch needs no 

2111 # extra state. 

2112 self._model_switch_queued = True 

2113 self.app.notify( 

2114 msg.MODEL_SWAP_QUEUED.format(name=display_label_for_ref(cfg.chat_model)) 

2115 ) 

2116 return 

2117 self.swapping_model = True 

2118 self.app.notify(msg.MODEL_SWAP_APPLYING) 

2119 self._reload_chat_model_worker() 

2120 

2121 def _apply_input_busy_state(self) -> None: 

2122 """Disable the chat input while a swap or placement reload is loading, and 

2123 say why in the placeholder so a person is never left facing a dead input 

2124 with no explanation. 

2125 

2126 Restores focus and the default placeholder when the fleet is idle again so 

2127 the user can type without re-clicking the input that was disabled out from 

2128 under them. Guarded because the unblock can fire (via ``call_from_thread`` 

2129 or a bubbled message) after the user navigated away and the input is no 

2130 longer mounted. 

2131 """ 

2132 no_model = not self.app.chat_is_ready 

2133 busy = self.swapping_model or self.reloading_placement or no_model 

2134 with contextlib.suppress(NoMatches): 

2135 inp = self._chat_input 

2136 inp.disabled = busy 

2137 if no_model: 

2138 inp.placeholder = msg.CHAT_INPUT_NO_MODEL 

2139 elif self.swapping_model: 

2140 from lilbee.catalog.formatting import display_label_for_ref 

2141 

2142 inp.placeholder = msg.CHAT_INPUT_SWITCHING.format( 

2143 name=display_label_for_ref(cfg.chat_model) 

2144 ) 

2145 elif self.reloading_placement: 

2146 inp.placeholder = msg.CHAT_INPUT_RELOADING 

2147 else: 

2148 inp.placeholder = msg.CHAT_INPUT_PLACEHOLDER_DEFAULT 

2149 if not busy and self._insert_mode: 

2150 inp.focus() 

2151 

2152 def watch_swapping_model(self, swapping: bool) -> None: 

2153 self._apply_input_busy_state() 

2154 

2155 def watch_reloading_placement(self, reloading: bool) -> None: 

2156 self._apply_input_busy_state() 

2157 

2158 def on_fleet_body_placement_reloading(self, event: FleetBody.PlacementReloading) -> None: 

2159 """Hold chat submissions while the Fleet drawer reloads the fleet.""" 

2160 self.reloading_placement = event.active 

2161 

2162 @work(thread=True, name=_MODEL_SWAP_WORKER, exit_on_error=False) 

2163 def _reload_chat_model_worker(self) -> None: 

2164 """Reload the chat role and warm the new model before unblocking the input. 

2165 

2166 ``reload_role(wait=True)`` re-plans and restarts the fleet for the new chat 

2167 model (retrieval is untouched) and returns once the proxy is back up. The 

2168 model is then warmed here rather than deferred to the user's next prompt: 

2169 ``request_engine_warm`` drives the load and populates the provider warm 

2170 tracker, which the task-bar footer renders (spinner, model, phase), and 

2171 ``wait_chat_ready`` holds the input disabled until the model actually 

2172 serves -- so the switch never hands back a live input in front of a model 

2173 that has not loaded. The provider serializes overlapping reloads, so a 

2174 rapid second swap coalesces onto the latest cfg. 

2175 """ 

2176 from lilbee.app.placement import ( 

2177 chat_warm_error, 

2178 request_engine_warm, 

2179 wait_chat_ready, 

2180 ) 

2181 

2182 worker = _get_worker() 

2183 try: 

2184 get_services().reload_role(WorkerRole.CHAT, wait=True) 

2185 request_engine_warm() 

2186 ready = wait_chat_ready(should_abort=lambda: worker.is_cancelled) 

2187 except Exception as exc: # any reload failure becomes a toast, never a crash 

2188 call_from_thread(self, self._on_model_swap_failed, str(exc)) 

2189 return 

2190 if worker.is_cancelled: 

2191 return 

2192 error = None if ready else chat_warm_error() 

2193 if error: 

2194 call_from_thread(self, self._on_model_swap_failed, error) 

2195 else: 

2196 call_from_thread(self, self._on_model_swapped) 

2197 

2198 def _on_model_swapped(self) -> None: 

2199 """Main-thread completion: unblock the input and confirm the new model.""" 

2200 from lilbee.catalog.formatting import display_label_for_ref 

2201 

2202 self.swapping_model = False 

2203 self.app.notify(msg.MODEL_SWAP_DONE.format(name=display_label_for_ref(cfg.chat_model))) 

2204 

2205 def _on_model_swap_failed(self, error: str) -> None: 

2206 """Main-thread failure: unblock the input and surface the error.""" 

2207 self.swapping_model = False 

2208 self.app.notify(msg.MODEL_SWAP_FAILED.format(error=error), severity="error") 

2209 

2210 @on(Markdown.LinkClicked) 

2211 def _open_answer_link(self, event: Markdown.LinkClicked) -> None: 

2212 """Open a link clicked in an answer: ``file:`` citations open in the OS 

2213 default app for the file type; web links open in the browser.""" 

2214 event.stop() 

2215 if event.href.startswith("file://"): 

2216 open_local_file(event.href) 

2217 else: 

2218 self.app.open_url(event.href) 

2219 

2220 async def action_toggle_markdown(self) -> None: 

2221 """Toggle between Markdown and plain-text rendering for chat responses.""" 

2222 cfg.markdown_rendering = not cfg.markdown_rendering 

2223 use_md = cfg.markdown_rendering 

2224 chat_log = self._chat_log 

2225 for widget in chat_log.query(AssistantMessage): 

2226 await widget.rebuild_content_widget(use_md) 

2227 label = "Markdown" if use_md else "Plain text" 

2228 self.notify(msg.CHAT_RENDERING.format(label=label)) 

2229 

2230 def _run_sync(self, *, force_rebuild: bool = False, prune_ignored: bool = False) -> None: 

2231 """Enqueue a document sync (or full rebuild) in the task bar.""" 

2232 if self._sync_active: 

2233 self.notify(msg.SYNC_ALREADY_ACTIVE, severity="warning") 

2234 return 

2235 from lilbee.cli.tui.task_queue import TaskType 

2236 

2237 self._sync_active = True 

2238 # Clear the pending hint so the bar shows live sync progress 

2239 # instead of the stale "N docs to sync" line. 

2240 self._task_bar.clear_pending_sync() 

2241 

2242 def _target(reporter: ProgressReporter) -> None: 

2243 try: 

2244 self._do_sync(reporter, force_rebuild=force_rebuild, prune_ignored=prune_ignored) 

2245 finally: 

2246 self._sync_active = False 

2247 # Re-detect after every sync attempt: success drives the 

2248 # count to 0, failure or cancel leaves the still-pending 

2249 # files counted so the hint reappears. 

2250 self._task_bar.start_detect_pending() 

2251 

2252 label = msg.TASK_NAME_REBUILD if force_rebuild else msg.TASK_NAME_SYNC 

2253 self._task_bar.start_task(label, TaskType.SYNC, _target, indeterminate=True) 

2254 

2255 def _do_sync( 

2256 self, 

2257 reporter: ProgressReporter, 

2258 *, 

2259 force_rebuild: bool = False, 

2260 prune_ignored: bool = False, 

2261 ) -> None: 

2262 """Sync body. Runs on worker thread.""" 

2263 from lilbee.data.ingest import sync 

2264 

2265 reporter.update(0, msg.SYNC_STATUS_SYNCING, indeterminate=True) 

2266 on_progress = build_sync_progress_callback(reporter) 

2267 try: 

2268 result = asyncio_loop.run( 

2269 sync( 

2270 quiet=True, 

2271 on_progress=on_progress, 

2272 force_rebuild=force_rebuild, 

2273 prune_ignored=prune_ignored, 

2274 ) 

2275 ) 

2276 except asyncio.CancelledError as exc: 

2277 raise RuntimeError(msg.SYNC_CANCELLED_RESUME) from exc 

2278 if prune_ignored: 

2279 call_from_thread(self, self.notify, msg.prune_ignored_message(len(result.removed))) 

2280 if result.failed: 

2281 raise RuntimeError(msg.SYNC_FAILED_FILES.format(files=", ".join(result.failed))) 

2282 if result.skipped: 

2283 call_from_thread( 

2284 self, 

2285 self.notify, 

2286 msg.sync_skipped_message(", ".join(result.skipped)), 

2287 severity="warning", 

2288 ) 

2289 if result.held_out: 

2290 call_from_thread( 

2291 self, 

2292 self.notify, 

2293 msg.SYNC_HELD_OUT.format(count=len(result.held_out)), 

2294 severity="warning", 

2295 ) 

2296 

2297 def action_focus_commands(self) -> None: 

2298 """Focus chat input and pre-fill with '/' for command entry.""" 

2299 # Route through the helper so can_focus is re-enabled when this 

2300 # action fires from NORMAL mode; bare ``inp.focus()`` would 

2301 # silently no-op while the input is intentionally unfocusable. 

2302 self._enter_insert_mode() 

2303 inp = self._chat_input 

2304 if not inp.value.startswith("/"): 

2305 inp.value = "/" 

2306 inp.action_end() 

2307 

2308 def action_toggle_chat_mode(self) -> None: 

2309 """F3: flip between Search and Chat mode.""" 

2310 try: 

2311 toggle = self.query_one(ChatModeToggle) 

2312 except NoMatches: 

2313 return 

2314 if not toggle.toggle(): 

2315 return 

2316 label = ( 

2317 msg.CHAT_MODE_SEARCH_LABEL 

2318 if cfg.chat_mode == ChatMode.SEARCH.value 

2319 else msg.CHAT_MODE_CHAT_LABEL 

2320 ) 

2321 self.notify(msg.CHAT_MODE_SET.format(label=label)) 

2322 

2323 def action_cycle_scope(self) -> None: 

2324 """``s``: cycle the scope chip when it is currently visible.""" 

2325 from lilbee.cli.tui.widgets.scope_chip import ScopeChip 

2326 

2327 try: 

2328 chip = self.query_one("#scope-chip", ScopeChip) 

2329 except NoMatches: 

2330 return 

2331 if chip.has_class("-hidden"): 

2332 return 

2333 chip.cycle_scope() 

2334 

2335 def action_complete(self) -> None: 

2336 """Tab: fill the shared prefix, then cycle matches (readline / vim style). 

2337 

2338 - Insert mode + chat input focused + dropdown closed but matches 

2339 exist: open it, fill the longest common prefix, else preview the 

2340 first match. 

2341 - Insert mode + chat input focused + dropdown open: fill any further 

2342 shared prefix, otherwise preview the next match. 

2343 - Insert mode + chat input focused + no matches: insert ``\\t`` so 

2344 users can type tab characters directly. 

2345 - Normal mode or focus elsewhere: advance through the focus 

2346 chain so Tab still walks every focusable widget. 

2347 """ 

2348 inp = self._chat_input 

2349 if not self._insert_mode or not inp.has_focus: 

2350 self._tab_into_fleet_or_next() 

2351 return 

2352 overlay = self._completion_overlay 

2353 if not overlay.is_visible and not self._open_completions(): 

2354 inp.insert("\t") 

2355 return 

2356 if self._fill_common_prefix(): 

2357 return 

2358 self._preview_next() 

2359 

2360 def _focus_in_drawer(self) -> bool: 

2361 """True when keyboard focus is inside an open drawer, so Enter / i / a / o 

2362 reach that drawer's own controls instead of entering insert mode. 

2363 

2364 Asked of the Drawer base rather than one drawer class: a drawer that had 

2365 to name itself here would otherwise swallow its own Enter until someone 

2366 noticed. 

2367 """ 

2368 focused = self.focused 

2369 return bool(focused and any(isinstance(n, Drawer) for n in focused.ancestors_with_self)) 

2370 

2371 def _focus_in_model_bar(self) -> bool: 

2372 """True when focus is on any model-strip member. 

2373 

2374 Asked of the container rather than of each member class so a member 

2375 added later is covered without a second edit here. 

2376 """ 

2377 focused = self.focused 

2378 return bool(focused and any(isinstance(n, ModelBar) for n in focused.ancestors_with_self)) 

2379 

2380 def _tab_into_fleet_or_next(self) -> None: 

2381 """Jump Tab into the open Fleet drawer's first toggle so the placement 

2382 editor is reachable without tabbing past every widget; once focus is 

2383 inside the drawer, Tab cycles within it as usual.""" 

2384 drawers = self.screen.query(FleetDrawer) 

2385 if not drawers: 

2386 self.screen.focus_next() 

2387 return 

2388 drawer = drawers.first() 

2389 focused = self.screen.focused 

2390 inside = focused is not None and drawer in focused.ancestors_with_self 

2391 toggles = drawer.query(".dev-toggle") 

2392 if not inside and toggles: 

2393 toggles.first().focus() 

2394 return 

2395 self.screen.focus_next() 

2396 

2397 def action_complete_next(self) -> None: 

2398 """Ctrl+N: preview the next match, opening the dropdown if it is closed (vim ``<C-n>``).""" 

2399 if not self._chat_input.has_focus: 

2400 # Not a completion here; skip so an open overlay (e.g. the sessions 

2401 # drawer) can bind Ctrl+N instead of this priority binding eating it. 

2402 raise SkipAction() 

2403 if self._completion_overlay.is_visible or self._open_completions(): 

2404 self._preview_next() 

2405 

2406 def action_complete_prev(self) -> None: 

2407 """Ctrl+P: preview the previous match, opening the dropdown if it is closed.""" 

2408 if not self._chat_input.has_focus: 

2409 return 

2410 if self._completion_overlay.is_visible or self._open_completions(): 

2411 self._preview_prev() 

2412 

2413 def _preview_next(self) -> None: 

2414 """Preview the highlighted match if none is previewed yet, else step forward.""" 

2415 overlay = self._completion_overlay 

2416 if self._chat_input.value == self._completion_origin: 

2417 display = overlay.get_current() 

2418 else: 

2419 display = overlay.cycle_next() 

2420 if display is not None: 

2421 self._preview_completion(display) 

2422 

2423 def _preview_prev(self) -> None: 

2424 """Step the highlight backward (wrapping to the last match) and preview it.""" 

2425 display = self._completion_overlay.cycle_prev() 

2426 if display is not None: 

2427 self._preview_completion(display) 

2428 

2429 def _open_completions(self) -> bool: 

2430 """Show the dropdown for the current input and remember it as the origin.""" 

2431 options = get_completions(self._chat_input.value) 

2432 if not options: 

2433 return False 

2434 self._completion_origin = self._chat_input.value 

2435 self._completion_overlay.show_completions(options) 

2436 return True 

2437 

2438 def _completion_value(self, display: str) -> str: 

2439 """Full input text produced by accepting ``display``, keeping the typed prefix. 

2440 

2441 Path completions are basenames, so the directory the user already 

2442 typed (``~/``, ``./``, absolute) is preserved and only the final 

2443 segment is replaced. 

2444 """ 

2445 text = ( 

2446 self._completion_origin 

2447 if self._completion_origin is not None 

2448 else (self._chat_input.value) 

2449 ) 

2450 if " " not in text: 

2451 return display 

2452 cmd, _, partial = text.partition(" ") 

2453 if cmd.lower() in PATH_ARG_COMMANDS: 

2454 head = path_completion_prefix(partial) 

2455 return f"{cmd} {head}{display}" 

2456 return f"{cmd} {display}" 

2457 

2458 def _set_input(self, value: str) -> None: 

2459 """Replace the input value without triggering the live-refresh of the dropdown.""" 

2460 inp = self._chat_input 

2461 if inp.value == value: 

2462 return 

2463 # The setter posts Changed asynchronously; flag one event to ignore so 

2464 # the previewed candidate doesn't re-filter (and collapse) the dropdown. 

2465 # (The value setter already moves the cursor to the end.) 

2466 self._suppress_refresh += 1 

2467 inp.value = value 

2468 

2469 def _preview_completion(self, display: str) -> None: 

2470 """Write the highlighted candidate into the input, leaving the dropdown open.""" 

2471 self._set_input(self._completion_value(display)) 

2472 

2473 def _fill_common_prefix(self) -> bool: 

2474 """Extend the input to the longest prefix shared by all matches; True if it grew.""" 

2475 overlay = self._completion_overlay 

2476 values = [self._completion_value(d) for d in overlay.options] 

2477 shared = longest_common_prefix(values) 

2478 if len(shared) <= len(self._chat_input.value): 

2479 return False 

2480 self._set_input(shared) 

2481 # Re-filter for the newly completed prefix (descends into a directory, 

2482 # narrows the model list, etc.). 

2483 self._refresh_completion_overlay() 

2484 return True 

2485 

2486 def action_history_prev(self) -> None: 

2487 """Up arrow: cycle the dropdown if visible, else recall previous history entry.""" 

2488 if not self._insert_mode: 

2489 raise SkipAction() 

2490 inp = self._chat_input 

2491 if not inp.has_focus: 

2492 raise SkipAction() 

2493 # When the completion dropdown is up, Up navigates the dropdown 

2494 # (vim/Emacs-style) rather than recalling history. 

2495 overlay = self._completion_overlay 

2496 if overlay.is_visible: 

2497 self._preview_prev() 

2498 return 

2499 if not self._input_history: 

2500 raise SkipAction() 

2501 if self._history_index == -1: 

2502 self._history_index = len(self._input_history) - 1 

2503 elif self._history_index > 0: 

2504 self._history_index -= 1 

2505 else: 

2506 return 

2507 inp.value = self._input_history[self._history_index] 

2508 inp.action_end() 

2509 

2510 def action_history_next(self) -> None: 

2511 """Down arrow: cycle the dropdown if visible, else recall next history entry.""" 

2512 if not self._insert_mode: 

2513 raise SkipAction() 

2514 inp = self._chat_input 

2515 if not inp.has_focus: 

2516 raise SkipAction() 

2517 # When the completion dropdown is up, Down navigates the dropdown. 

2518 overlay = self._completion_overlay 

2519 if overlay.is_visible: 

2520 self._preview_next() 

2521 return 

2522 if self._history_index == -1: 

2523 raise SkipAction() 

2524 if self._history_index < len(self._input_history) - 1: 

2525 self._history_index += 1 

2526 inp.value = self._input_history[self._history_index] 

2527 inp.action_end() 

2528 else: 

2529 self._history_index = -1 

2530 inp.value = "" 

2531 

2532 @on(ChatInput.Changed, "#chat-input") 

2533 def _on_chat_input_changed(self, event: ChatInput.Changed) -> None: 

2534 """Refresh arg-hint and auto-show or hide the completion dropdown.""" 

2535 if self._suppress_refresh > 0: 

2536 # A programmatic edit (preview / accept / revert) is managing the 

2537 # overlay itself; consume one Changed and skip the live refresh. 

2538 self._suppress_refresh -= 1 

2539 self._refresh_arg_hint() 

2540 return 

2541 self._refresh_completion_overlay() 

2542 self._refresh_arg_hint() 

2543 

2544 def _refresh_completion_overlay(self) -> None: 

2545 """Live-filter the dropdown against the current input, in command and arg modes alike.""" 

2546 overlay = self._completion_overlay 

2547 text = self._chat_input.value 

2548 options = get_completions(text) 

2549 if options: 

2550 self._completion_origin = text 

2551 overlay.show_completions(options) 

2552 elif overlay.is_visible: 

2553 overlay.hide() 

2554 self._completion_origin = None 

2555 

2556 def _refresh_arg_hint(self) -> None: 

2557 """Push the current input value into the ArgHintLine.""" 

2558 self._arg_hint.update_for_input(self._chat_input.value) 

2559 

2560 def refresh_model_bar(self) -> None: 

2561 """Re-scan installed models and refresh the model bar. 

2562 

2563 Show can arrive before the prompt area's descendants have mounted, and 

2564 on a slow runner it does. A bar that is not in the DOM yet scans on its 

2565 own mount, so a missing bar means the scan is already coming, not that 

2566 it was skipped -- the re-scan here is what re-entering the screen needs. 

2567 Querying regardless raised NoMatches out of the show handler, which 

2568 Textual re-raises as an app crash. 

2569 """ 

2570 for bar in self.query("#model-bar").results(ModelBar): 

2571 bar.refresh_models() 

2572 

2573 def action_vim_scroll_down(self) -> None: 

2574 """Vim j: scroll down in normal mode.""" 

2575 if self._insert_mode: 

2576 raise SkipAction() 

2577 self._chat_log.scroll_down() 

2578 

2579 def action_vim_scroll_up(self) -> None: 

2580 """Vim k: scroll up in normal mode.""" 

2581 if self._insert_mode: 

2582 raise SkipAction() 

2583 self._chat_log.scroll_up() 

2584 

2585 def action_vim_scroll_home(self) -> None: 

2586 """Vim g: scroll to top in normal mode.""" 

2587 if self._insert_mode: 

2588 raise SkipAction() 

2589 self._chat_log.scroll_home() 

2590 

2591 def action_vim_scroll_end(self) -> None: 

2592 """Vim G: scroll to bottom in normal mode.""" 

2593 if self._insert_mode: 

2594 raise SkipAction() 

2595 self._chat_log.scroll_end() 

2596 

2597 def action_half_page_down(self) -> None: 

2598 """Ctrl-D: half-page down (vim style).""" 

2599 log_widget = self._chat_log 

2600 half = max(1, log_widget.size.height // 2) 

2601 log_widget.scroll_relative(y=half) 

2602 

2603 def action_half_page_up(self) -> None: 

2604 """Ctrl-U: half-page up (vim style).""" 

2605 log_widget = self._chat_log 

2606 half = max(1, log_widget.size.height // 2) 

2607 log_widget.scroll_relative(y=-half)