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

1387 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +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.runtime import asyncio_loop 

97from lilbee.runtime.progress import ( 

98 EventType, 

99 ProgressEvent, 

100) 

101from lilbee.sessions import ( 

102 MessageRole, 

103 SessionMessage, 

104 SessionNotFoundError, 

105 SessionOrigin, 

106 SessionStore, 

107 TitleSource, 

108 derive_title, 

109) 

110 

111if TYPE_CHECKING: 

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

113log = logging.getLogger(__name__) 

114 

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

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

117# message queue and makes key events visibly lag. 

118_STREAM_FLUSH_INTERVAL = 0.05 

119 

120 

121@dataclass 

122class _StreamTimings: 

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

124 

125 last_flush: float 

126 

127 

128# ``/crawl`` command flags. 

129_CRAWL_FLAG_DEPTH = "--depth" 

130_CRAWL_FLAG_MAX_PAGES = "--max-pages" 

131_CRAWL_FLAG_INCLUDE_SUBDOMAINS = "--include-subdomains" 

132_CRAWL_FLAG_RENDER = "--render" 

133 

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

135# event loop. 

136_MODEL_SWAP_WORKER = "model_swap_reset" 

137 

138 

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

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

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

142 from lilbee.catalog.formatting import display_label_for_ref 

143 

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

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

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

147 if snapshot.phase is WarmPhase.LOADING_ENGINE: 

148 return msg.ENGINE_ALMOST_READY 

149 return msg.ENGINE_WARMING 

150 

151 

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

153 

154 

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

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

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

158 

159 

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

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

162 low = name.lower() 

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

164 if len(contains) == 1: 

165 return contains[0] 

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

167 return matches[0] if matches else None 

168 

169 

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

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

172 

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

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

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

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

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

178 """ 

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

180 if whole.exists(): 

181 return [whole] 

182 try: 

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

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

185 except ValueError: 

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

187 if os.name == "nt": 

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

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

190 

191 

192class ChatWelcome(Static): 

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

194 

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

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

197 

198 @staticmethod 

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

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

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

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

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

204 

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

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

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

208 self.update(self._body(hint)) 

209 

210 

211class PromptArea(Vertical): 

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

213 

214 pass 

215 

216 

217class ChatScreen(Screen[None]): 

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

219 

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

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

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

223 app: LilbeeApp # type: ignore[assignment] 

224 

225 CSS_PATH = "chat.tcss" 

226 AUTO_FOCUS = "#chat-input" 

227 

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

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

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

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

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

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

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

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

236 

237 HELP = ( 

238 "# Chat\n\n" 

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

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

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

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

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

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

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

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

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

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

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

250 ) 

251 

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

253 

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

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

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

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

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

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

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

261 

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

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

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

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

266 # F2 opens the searchable list of every slash command 

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

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

269 Binding( 

270 "f2", 

271 "show_command_catalog", 

272 "All commands", 

273 show=False, 

274 priority=True, 

275 ), 

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

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

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

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

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

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

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

283 # LilbeeApp.action_command_palette overriding to call 

284 # ChatScreen.action_complete_prev. Action is exposed for direct 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

307 # check_action below hides + disables it outside that exact 

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

309 # nothing is streaming. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

325 # action_scroll_left raises SkipAction when there is nothing to scroll 

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

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

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

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

330 ] 

331 

332 def __init__(self) -> None: 

333 super().__init__() 

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

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

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

337 self._summary = "" 

338 self._history_lock = threading.Lock() 

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

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

341 # fresh session. 

342 self._session_id: str | None = None 

343 self._insert_mode: bool = True 

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

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

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

347 self._suppress_refresh = 0 

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

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

350 self._completion_origin: str | None = None 

351 self._sync_active: bool = False 

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

353 self._history_index: int = -1 

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

355 # has to wait out a cold engine load. 

356 self._warm_tip_shown: bool = False 

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

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

359 self._active_assistant: AssistantMessage | None = None 

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

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

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

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

364 self._active_question: UserMessage | None = None 

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

366 self._model_switch_queued: bool = False 

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

368 

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

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

371 

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

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

374 """ 

375 from lilbee.cli.tui.command_registry import COMMANDS 

376 

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

378 for cmd in COMMANDS: 

379 method = getattr(self, cmd.handler) 

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

381 handlers[name] = method 

382 return handlers 

383 

384 @property 

385 def _task_bar(self) -> TaskBarController: 

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

387 return self.app.task_bar 

388 

389 def compose(self) -> ComposeResult: 

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

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

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

393 

394 with TopBars(): 

395 yield ViewTabs() 

396 yield VerticalScroll( 

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

398 id="chat-log", 

399 ) 

400 with BottomBars(): 

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

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

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

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

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

406 yield ChatInput( 

407 placeholder=msg.CHAT_INPUT_PLACEHOLDER_DEFAULT, 

408 id="chat-input", 

409 ) 

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

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

412 yield TaskBar() 

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

414 # prompt block its own row. 

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

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

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

418 yield Footer() 

419 

420 def on_mount(self) -> None: 

421 self._update_input_style() 

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

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

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

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

426 

427 def on_show(self) -> None: 

428 """Called when screen becomes visible.""" 

429 from lilbee.runtime.splash import dismiss 

430 

431 dismiss() 

432 self.refresh_model_bar() 

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

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

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

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

437 # so global bindings keep firing). 

438 with contextlib.suppress(Exception): 

439 if self._insert_mode: 

440 self._enter_insert_mode() 

441 else: 

442 self._chat_log.focus() 

443 

444 def _embedding_ready(self) -> bool: 

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

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

447 

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

449 key, _value = payload 

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

451 self.refresh_model_bar() 

452 

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

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

455 with contextlib.suppress(NoMatches): 

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

457 self._apply_input_busy_state() 

458 

459 def _enter_insert_mode(self) -> None: 

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

461 self._insert_mode = True 

462 self._chat_input.can_focus = True 

463 self._chat_input.focus() 

464 self._update_input_style() 

465 

466 def focus_prompt(self) -> None: 

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

468 

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

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

471 """ 

472 self._enter_insert_mode() 

473 

474 def action_focus_model_bar(self) -> None: 

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

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

477 

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

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

480 

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

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

483 """ 

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

485 

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

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

488 if self._reject_submit_when_busy(text): 

489 return 

490 self._handle_slash(text) 

491 

492 def _update_input_style(self) -> None: 

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

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

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

496 with contextlib.suppress(NoMatches): 

497 inp = self._chat_input 

498 if self._insert_mode: 

499 inp.remove_class("normal-mode") 

500 else: 

501 inp.add_class("normal-mode") 

502 self._update_mode_indicator() 

503 

504 def _update_mode_indicator(self) -> None: 

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

506 with contextlib.suppress(NoMatches): 

507 bar = self.query_one(ViewTabs) 

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

509 

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

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

512 from textual.events import Key 

513 

514 if not isinstance(event, Key): 

515 return 

516 inp = self._chat_input 

517 if self._insert_mode: 

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

519 inp.focus() 

520 inp.insert(event.character) 

521 event.prevent_default() 

522 event.stop() 

523 return 

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

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

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

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

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

529 # dropped to INSERT instead of switching the mode. 

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

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

532 ): 

533 return 

534 if self._focus_in_drawer(): 

535 return 

536 self._enter_insert_mode() 

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

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

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

540 self._submit_draft(inp, inp.value) 

541 event.prevent_default() 

542 event.stop() 

543 return 

544 

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

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

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

548 

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

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

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

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

553 the screen mode here. 

554 """ 

555 if not self._insert_mode: 

556 self._enter_insert_mode() 

557 

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

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

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

561 

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

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

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

565 """ 

566 if not self._insert_mode: 

567 self._enter_insert_mode() 

568 event.stop() 

569 

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

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

572 

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

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

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

576 """ 

577 if not self._insert_mode: 

578 return 

579 if event.widget is None: 

580 return 

581 chat_input = self._chat_input 

582 node: DOMNode | None = event.widget 

583 while node is not None: 

584 if node is chat_input: 

585 return 

586 node = node.parent 

587 self.action_enter_normal_mode() 

588 

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

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

591 if not self._insert_mode: 

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

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

594 self._enter_insert_mode() 

595 return 

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

597 

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

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

600 text = value.strip() 

601 if not self._ready_to_submit(text): 

602 return 

603 chat_input.value = "" 

604 self._input_history.append(text) 

605 self._history_index = -1 

606 

607 if text.startswith("/"): 

608 self._handle_slash(text) 

609 return 

610 self._send_message(text) 

611 

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

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

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

615 return False 

616 cmd = self._slash_name(text) 

617 if cmd: 

618 if cmd not in self._command_handlers: 

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

620 # fixed in place instead of retyped. 

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

622 return False 

623 return True 

624 pending = self._pending_required_model_download() 

625 if pending is not None: 

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

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

628 self.notify( 

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

630 severity="warning", 

631 timeout=5, 

632 ) 

633 return False 

634 return True 

635 

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

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

638 

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

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

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

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

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

644 """ 

645 if self.swapping_model: 

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

647 return True 

648 if self.reloading_placement: 

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

650 return True 

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

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

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

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

655 return True 

656 return False 

657 

658 @staticmethod 

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

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

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

662 

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

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

665 

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

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

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

669 """ 

670 task_bar = self.app.task_bar 

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

672 label = task_bar.downloading_label_for(ref) 

673 if label is not None: 

674 return label 

675 return None 

676 

677 def _dismiss_overlay_on_submit(self) -> bool: 

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

679 

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

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

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

683 a submission. 

684 """ 

685 overlay = self._completion_overlay 

686 if overlay.is_visible: 

687 overlay.hide() 

688 self._completion_origin = None 

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

690 self._set_input("") 

691 return True 

692 return False 

693 

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

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

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

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

698 handler = self._command_handlers.get(cmd) 

699 if handler is not None: 

700 handler(args) 

701 else: 

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

703 

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

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

706 self.streaming = value 

707 

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

709 if streaming: 

710 self._enter_streaming_state() 

711 else: 

712 self._exit_streaming_state() 

713 

714 def _enter_streaming_state(self) -> None: 

715 self.add_class("streaming") 

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

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

718 self.refresh_bindings() 

719 

720 def _exit_streaming_state(self) -> None: 

721 self.remove_class("streaming") 

722 self.refresh_bindings() 

723 if self._model_switch_queued: 

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

725 self._model_switch_queued = False 

726 self.apply_model_change() 

727 

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

729 from lilbee.app.ingest import source_label_taken 

730 

731 if not args: 

732 return 

733 if self._sync_active: 

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

735 return 

736 if is_url(args): 

737 self._cmd_crawl(args) 

738 return 

739 paths = _parse_add_paths(args) 

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

741 if missing: 

742 self.notify( 

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

744 severity="error", 

745 ) 

746 return 

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

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

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

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

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

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

753 if duplicates: 

754 self._prompt_overwrite(paths, duplicates) 

755 return 

756 self._submit_add(paths, force=False) 

757 

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

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

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

761 

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

763 

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

765 if not confirmed: 

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

767 return 

768 self._submit_add(paths, force=True) 

769 

770 self.app.push_screen( 

771 ConfirmDialog( 

772 msg.CMD_ADD_DUPLICATE_TITLE, 

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

774 ), 

775 _on_confirm, 

776 ) 

777 

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

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

780 from lilbee.cli.tui.task_queue import TaskType 

781 

782 self._sync_active = True 

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

784 

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

786 try: 

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

788 finally: 

789 self._sync_active = False 

790 

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

792 

793 def _do_add( 

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

795 ) -> None: 

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

797 from lilbee.app.ingest import register_sources 

798 from lilbee.data.ingest import sync 

799 

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

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

802 reg_result = register_sources(paths, force=force) 

803 registered = reg_result.registered 

804 for name in reg_result.skipped: 

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

806 if reg_result.tracked: 

807 call_from_thread( 

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

809 ) 

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

811 

812 try: 

813 sync_result = asyncio_loop.run( 

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

815 ) 

816 except BaseException: 

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

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

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

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

821 unregister_added_roots(registered) 

822 raise 

823 if sync_result.failed: 

824 unregister_added_roots(registered) 

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

826 if sync_result.skipped: 

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

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

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

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

831 unregister_added_roots(registered) 

832 raise RuntimeError(skipped_msg) 

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

834 if sync_result.relocated: 

835 call_from_thread( 

836 self, 

837 self.notify, 

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

839 ) 

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

841 

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

843 # _cancel_inflight_stream already cancels every screen worker, so the 

844 # two branches each cancel everything exactly once. 

845 if self.streaming: 

846 self._cancel_inflight_stream(msg.STREAM_CANCELLED) 

847 else: 

848 for worker in self.workers: 

849 worker.cancel() 

850 self.notify(msg.CMD_CANCEL) 

851 

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

853 self._reset_conversation() 

854 self.notify(msg.CMD_CLEAR) 

855 

856 def _reset_conversation(self) -> None: 

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

858 

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

860 next user turn open a fresh one. 

861 """ 

862 if self.streaming: 

863 self._cancel_inflight_stream(msg.STREAM_CANCELLED) 

864 else: 

865 for worker in self.workers: 

866 worker.cancel() 

867 self.streaming = False 

868 self._chat_log.remove_children() 

869 self._active_assistant = None 

870 self._active_question = None 

871 with self._history_lock: 

872 self._history.clear() 

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

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

875 self._summary = "" 

876 self._session_id = None 

877 

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

879 if not crawler_available(): 

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

881 return 

882 if not args: 

883 self._open_crawl_dialog() 

884 return 

885 parts = args.split() 

886 url = parts[0] 

887 if not is_url(url): 

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

889 try: 

890 require_valid_crawl_url(url) 

891 except ValueError as exc: 

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

893 return 

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

895 self._start_crawl( 

896 url, 

897 depth, 

898 max_pages, 

899 include_subdomains=include_subdomains, 

900 render_mode=render_mode, 

901 ) 

902 

903 def _open_crawl_dialog(self) -> None: 

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

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

906 

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

908 if result is not None: 

909 self._start_crawl( 

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

911 ) 

912 

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

914 

915 def _start_crawl( 

916 self, 

917 url: str, 

918 depth: int | None, 

919 max_pages: int | None, 

920 *, 

921 include_subdomains: bool = False, 

922 render_mode: CrawlRenderMode | None = None, 

923 ) -> None: 

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

925 

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

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

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

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

930 sticks for the next crawl. 

931 """ 

932 from lilbee.cli.tui.task_queue import TaskType 

933 

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

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

936 self._persist_crawl_render_mode(render_mode) 

937 

938 def _kick_off_crawl() -> None: 

939 self._task_bar.start_task( 

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

941 TaskType.CRAWL, 

942 lambda reporter: self._do_crawl( 

943 url, 

944 depth, 

945 max_pages, 

946 reporter, 

947 include_subdomains=include_subdomains, 

948 render_mode=mode, 

949 ), 

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

951 ) 

952 

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

954 if mode is CrawlRenderMode.BROWSER: 

955 self._task_bar.ensure_chromium(_kick_off_crawl) 

956 else: 

957 _kick_off_crawl() 

958 

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

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

961 from lilbee.app.settings import apply_settings_update 

962 

963 try: 

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

965 except (ValueError, OSError) as exc: 

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

967 

968 @staticmethod 

969 def _parse_crawl_flags( 

970 tokens: list[str], 

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

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

973 

974 Numeric flags return None when absent so the caller inherits 

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

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

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

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

979 """ 

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

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

982 include_subdomains = False 

983 render_mode: CrawlRenderMode | None = None 

984 i = 0 

985 while i < len(tokens): 

986 if tokens[i] == _CRAWL_FLAG_INCLUDE_SUBDOMAINS: 

987 include_subdomains = True 

988 i += 1 

989 continue 

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

991 with contextlib.suppress(ValueError): 

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

993 i += 2 

994 continue 

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

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

997 with contextlib.suppress(ValueError): 

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

999 i += 2 

1000 else: 

1001 i += 1 

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

1003 

1004 def _do_crawl( 

1005 self, 

1006 url: str, 

1007 depth: int | None, 

1008 max_pages: int | None, 

1009 reporter: ProgressReporter, 

1010 *, 

1011 include_subdomains: bool = False, 

1012 render_mode: CrawlRenderMode | None = None, 

1013 ) -> None: 

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

1015 from lilbee.crawler import crawl_and_save 

1016 from lilbee.runtime.progress import CrawlPageEvent, SetupProgressEvent 

1017 

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

1019 

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

1021 if event_type == EventType.SETUP_START: 

1022 reporter.update(0, msg.SETUP_CHROMIUM_NAME) 

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

1024 if data.total_bytes: 

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

1026 detail = msg.SETUP_CHROMIUM_DETAIL.format( 

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

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

1029 ) 

1030 else: 

1031 pct = 0 

1032 detail = msg.SETUP_CHROMIUM_DETAIL_UNKNOWN.format( 

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

1034 ) 

1035 reporter.update(pct, detail) 

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

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

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

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

1040 # soon as the total is known. 

1041 if data.total > 0: 

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

1043 reporter.update( 

1044 pct, 

1045 msg.CMD_CRAWL_PAGE.format( 

1046 current=data.current, total=data.total, url=data.url 

1047 ), 

1048 indeterminate=False, 

1049 ) 

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

1051 reporter.update( 

1052 0, 

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

1054 indeterminate=True, 

1055 ) 

1056 

1057 paths = asyncio_loop.run( 

1058 crawl_and_save( 

1059 url, 

1060 depth=depth, 

1061 max_pages=max_pages, 

1062 on_progress=on_progress, 

1063 quiet=True, 

1064 include_subdomains=include_subdomains, 

1065 render_mode=render_mode, 

1066 ) 

1067 ) 

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

1069 

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

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

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

1073 self.app.switch_view(msg.CATALOG_VIEW) 

1074 

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

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

1077 self._cmd_delete_worker(args.strip()) 

1078 

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

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

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

1082 try: 

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

1084 except Exception: 

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

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

1087 return 

1088 

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

1090 if not known: 

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

1092 return 

1093 

1094 if not name: 

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

1096 call_from_thread(self, self.notify, usage) 

1097 return 

1098 

1099 if name not in known: 

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

1101 suggestion = _closest_source(name, known) 

1102 if suggestion is not None: 

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

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

1105 return 

1106 

1107 from lilbee.app.ingest import remove_documents_durably 

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

1109 

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

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

1112 remove_documents_durably([name]) 

1113 invalidate_document_cache() 

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

1115 

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

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

1118 path = args.strip() 

1119 if not path: 

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

1121 return 

1122 from lilbee.cli.tui.task_queue import TaskType 

1123 

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

1125 self._do_export(path, reporter) 

1126 

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

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

1129 

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

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

1132 from lilbee.app.dataset import DatasetError, export_to_path 

1133 

1134 output = Path(raw_path).expanduser() 

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

1136 try: 

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

1138 except DatasetError as exc: 

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

1140 raise RuntimeError(str(exc)) from exc 

1141 call_from_thread( 

1142 self, 

1143 self.notify, 

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

1145 ) 

1146 

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

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

1149 path = args.strip() 

1150 if not path: 

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

1152 return 

1153 if self._sync_active: 

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

1155 return 

1156 from lilbee.cli.tui.task_queue import TaskType 

1157 

1158 self._sync_active = True 

1159 

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

1161 try: 

1162 self._do_import(path, reporter) 

1163 finally: 

1164 self._sync_active = False 

1165 self._task_bar.start_detect_pending() 

1166 

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

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

1169 

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

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

1172 from lilbee.app.dataset import DatasetError, import_from_path 

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

1174 

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

1176 try: 

1177 summary = asyncio_loop.run( 

1178 import_from_path( 

1179 Path(raw_path).expanduser(), 

1180 "", 

1181 on_progress=build_import_progress_callback(reporter), 

1182 ) 

1183 ) 

1184 except DatasetError as exc: 

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

1186 raise RuntimeError(str(exc)) from exc 

1187 invalidate_document_cache() 

1188 call_from_thread( 

1189 self, 

1190 self.notify, 

1191 msg.CMD_IMPORT_SUCCESS.format( 

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

1193 ), 

1194 ) 

1195 

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

1197 self.action_show_command_catalog() 

1198 

1199 def action_show_command_catalog(self) -> None: 

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

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

1202 

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

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

1205 self._enter_insert_mode() 

1206 inp = self._chat_input 

1207 inp.value = f"{name} " 

1208 inp.action_end() 

1209 

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

1211 if name is None: 

1212 return 

1213 self.insert_slash_command(name) 

1214 

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

1216 token = args.strip() 

1217 if not token: 

1218 import webbrowser 

1219 

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

1221 self.notify(msg.CHAT_LOGIN_PROMPT) 

1222 return 

1223 self._run_hf_login(token) 

1224 

1225 @work(thread=True) 

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

1227 try: 

1228 from huggingface_hub import login 

1229 

1230 login(token=token, add_to_git_credential=False) 

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

1232 except Exception as exc: 

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

1234 call_from_thread( 

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

1236 ) 

1237 

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

1239 if args: 

1240 from lilbee.catalog.formatting import display_label_for_ref 

1241 

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

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

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

1245 self.apply_model_change() 

1246 self.refresh_model_bar() 

1247 else: 

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

1249 

1250 self.app.push_screen(CatalogScreen()) 

1251 

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

1253 self.app.exit() 

1254 

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

1256 name = args.strip() 

1257 if not name: 

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

1259 return 

1260 self._run_remove_model(name) 

1261 

1262 @work(thread=True) 

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

1264 mgr = get_services().model_manager 

1265 if not mgr.is_installed(name): 

1266 call_from_thread( 

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

1268 ) 

1269 return 

1270 try: 

1271 removed = mgr.remove(name) 

1272 if removed: 

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

1274 else: 

1275 call_from_thread( 

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

1277 ) 

1278 except Exception: 

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

1280 call_from_thread( 

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

1282 ) 

1283 

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

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

1286 

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

1288 if not confirmed: 

1289 return 

1290 self._run_sync(force_rebuild=True) 

1291 

1292 self.app.push_screen( 

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

1294 _on_confirm, 

1295 ) 

1296 

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

1298 self.request_reset() 

1299 

1300 def request_reset(self) -> None: 

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

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

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 from lilbee.app.reset import perform_reset 

1309 

1310 try: 

1311 result = perform_reset() 

1312 except Exception as exc: 

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

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

1315 return 

1316 

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

1318 reset_store() 

1319 

1320 if result.skipped: 

1321 self.notify( 

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

1323 severity="warning", 

1324 ) 

1325 else: 

1326 self.notify(msg.CMD_RESET_SUCCESS) 

1327 

1328 self.app.push_screen( 

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

1330 _on_confirm, 

1331 ) 

1332 

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

1334 if not args: 

1335 return 

1336 parts = args.split(None, 1) 

1337 key = parts[0] 

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

1339 

1340 if key not in SETTINGS_MAP: 

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

1342 return 

1343 

1344 defn = SETTINGS_MAP[key] 

1345 if not defn.writable: 

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

1347 return 

1348 try: 

1349 if defn.type is bool: 

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

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

1352 parsed = None 

1353 else: 

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

1355 self.notify( 

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

1357 severity="error", 

1358 ) 

1359 return 

1360 try: 

1361 parsed = defn.type(value) 

1362 except (ValueError, TypeError): 

1363 self.notify( 

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

1365 severity="error", 

1366 ) 

1367 return 

1368 # Route through set_setting so settings_changed_signal subscribers 

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

1370 # _invalidate_caches now handles llm_provider service reset. 

1371 self.app.set_setting(key, parsed) 

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

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

1374 except (ValueError, TypeError) as exc: 

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

1376 

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

1378 self.app.switch_view("Settings") 

1379 

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

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

1382 self._cmd_remember_worker(args) 

1383 

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

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

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

1387 outcome = remember_from_input(raw) 

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

1389 

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

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

1392 

1393 self.app.push_screen(MemoriesScreen()) 

1394 

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

1396 self.app.switch_view("Status") 

1397 

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

1399 if not args: 

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

1401 self.insert_slash_command("/theme") 

1402 return 

1403 if args not in DARK_THEMES: 

1404 self.notify( 

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

1406 severity="warning", 

1407 ) 

1408 return 

1409 self.app.set_theme(args) 

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

1411 

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

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

1414 

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

1416 if not cfg.wiki: 

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

1418 return 

1419 self.app.switch_view("Wiki") 

1420 

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

1422 self.app.action_toggle_sessions() 

1423 

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

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

1426 from textual.css.query import NoMatches 

1427 

1428 log = self._chat_log 

1429 with contextlib.suppress(NoMatches): 

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

1431 question = UserMessage(text) 

1432 log.mount(question) 

1433 self._active_question = question 

1434 

1435 # The assistant bubble owns its own ThinkingHeader animator until 

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

1437 assistant_msg = AssistantMessage() 

1438 self._active_assistant = assistant_msg 

1439 log.mount(assistant_msg) 

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

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

1442 log.anchor() 

1443 

1444 with self._history_lock: 

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

1446 self._persist_user_turn(text) 

1447 self.streaming = True 

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

1449 

1450 def _current_scope_value(self) -> str: 

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

1452 from textual.css.query import NoMatches 

1453 

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

1455 

1456 try: 

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

1458 except NoMatches: 

1459 return SearchScope.BOTH.value 

1460 return chip.scope 

1461 

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

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

1464 

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

1466 otherwise. 

1467 """ 

1468 return scope_to_chunk_type(self._current_scope_value()) 

1469 

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

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

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

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

1474 self._session_id = session_id 

1475 return session_id 

1476 

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

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

1479 if not cfg.sessions_enabled: 

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

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

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

1483 return 

1484 store = get_services().session_store 

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

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

1487 try: 

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

1489 except SessionNotFoundError: 

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

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

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

1493 

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

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

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

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

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

1499 # relying on _persist_user_turn having left the id unset. 

1500 return 

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

1502 with contextlib.suppress(SessionNotFoundError): 

1503 get_services().session_store.add_message( 

1504 self._session_id, 

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

1506 surface=SessionOrigin.TUI, 

1507 ) 

1508 

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

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

1511 store = get_services().session_store 

1512 session = store.get(session_id) 

1513 self._reset_conversation() 

1514 self._session_id = session_id 

1515 for message in session.messages: 

1516 self._render_restored_message(message) 

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

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

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

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

1521 # what a resumed conversation must not do. 

1522 loaded: list[ChatMessage] = [ 

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

1524 ] 

1525 with self._history_lock: 

1526 self._history = loaded 

1527 self._summary = session.summary 

1528 self._restore_session_model(session.meta.model_ref) 

1529 self._refresh_context_usage() 

1530 self._chat_log.scroll_end(animate=False) 

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

1532 

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

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

1535 

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

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

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

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

1540 """ 

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

1542 return 

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

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

1545 else: 

1546 self.notify( 

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

1548 severity="warning", 

1549 ) 

1550 

1551 @property 

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

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

1554 return self._session_id 

1555 

1556 def start_new_conversation(self) -> None: 

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

1558 self._reset_conversation() 

1559 self.notify(msg.SESSIONS_NEW) 

1560 

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

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

1563 log = self._chat_log 

1564 if message.role == MessageRole.USER: 

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

1566 return 

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

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

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

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

1571 

1572 @work(thread=True) 

1573 def _stream_response( 

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

1575 ) -> None: 

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

1577 self._do_stream_response(question, widget, chunk_type) 

1578 

1579 def _do_stream_response( 

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

1581 ) -> None: 

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

1583 response_parts: list[str] = [] 

1584 sources: list[str] = [] 

1585 stream: Any = None 

1586 try: 

1587 if not self._await_chat_engine(widget): 

1588 return 

1589 self._compact_history() 

1590 with self._history_lock: 

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

1592 recent = self._history[:-1] 

1593 summary = self._summary 

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

1595 stream = get_services().searcher.ask_stream( 

1596 question, history=history_snapshot, chunk_type=chunk_type 

1597 ) 

1598 self._consume_stream(stream, widget, response_parts) 

1599 except EmbeddingModelMismatchError as exc: 

1600 with contextlib.suppress(Exception): 

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

1602 except Exception as exc: 

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

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

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

1606 if not self._stream_worker_cancelled(): 

1607 with contextlib.suppress(Exception): 

1608 call_from_thread( 

1609 self, widget.append_content, msg.STREAM_ERROR.format(error=exc) 

1610 ) 

1611 finally: 

1612 close_stream(stream) 

1613 self._finalize_stream(widget, sources, response_parts) 

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

1615 

1616 @staticmethod 

1617 def _stream_worker_cancelled() -> bool: 

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

1619 try: 

1620 return _get_worker().is_cancelled 

1621 except NoActiveWorker: 

1622 return False 

1623 

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

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

1626 

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

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

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

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

1631 already rendered into the bubble. 

1632 """ 

1633 from lilbee.app.placement import ( 

1634 chat_engine_ready, 

1635 chat_warm_error, 

1636 request_engine_warm, 

1637 wait_chat_ready, 

1638 ) 

1639 

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

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

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

1643 # lazily rebuilding the way ask_stream always has. 

1644 get_services() 

1645 if chat_engine_ready(): 

1646 return True 

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

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

1649 request_engine_warm() 

1650 self._show_warm_tip_once() 

1651 worker = _get_worker() 

1652 

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

1654 with contextlib.suppress(Exception): 

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

1656 

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

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

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

1660 with contextlib.suppress(Exception): 

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

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

1663 with contextlib.suppress(Exception): 

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

1665 return True 

1666 if worker.is_cancelled: 

1667 return False 

1668 error = chat_warm_error() 

1669 text = ( 

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

1671 if error is not None 

1672 else msg.ENGINE_NOT_READY 

1673 ) 

1674 with contextlib.suppress(Exception): 

1675 call_from_thread(self, widget.append_content, text) 

1676 return False 

1677 

1678 def _show_warm_tip_once(self) -> None: 

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

1680 if cfg.keep_engine_warm or self._warm_tip_shown: 

1681 return 

1682 self._warm_tip_shown = True 

1683 with contextlib.suppress(Exception): 

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

1685 

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

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

1688 

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

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

1691 """ 

1692 from lilbee.app.memory import auto_extract_enabled 

1693 

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

1695 return 

1696 self._extract_memories_worker(question, answer) 

1697 

1698 def _indexing_active(self) -> bool: 

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

1700 

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

1702 re-indexes the page it publishes. 

1703 """ 

1704 from lilbee.cli.tui.task_queue import TaskType 

1705 

1706 busy = { 

1707 TaskType.SYNC.value, 

1708 TaskType.ADD.value, 

1709 TaskType.IMPORT.value, 

1710 TaskType.WIKI.value, 

1711 } 

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

1713 

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

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

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

1717 from lilbee.app.memory import auto_extract 

1718 

1719 stored = auto_extract(question, answer) 

1720 if stored: 

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

1722 

1723 def _on_embedding_mismatch( 

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

1725 ) -> None: 

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

1727 if not exc.dims_match: 

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

1729 return 

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

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

1732 

1733 self.app.push_screen( 

1734 ConfirmDialog( 

1735 msg.EMBED_ADOPT_CONFIRM_TITLE, 

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

1737 ), 

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

1739 ) 

1740 

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

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

1743 if not confirmed: 

1744 self.notify(msg.EMBED_ADOPT_CANCELLED) 

1745 return 

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

1747 self._adopt_and_retry(ref, question) 

1748 

1749 @work(thread=True) 

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

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

1752 self._do_adopt_and_retry(ref, question) 

1753 

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

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

1756 from lilbee.app.models import adopt_embedder 

1757 

1758 try: 

1759 adopt_embedder(ref) 

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

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

1762 call_from_thread( 

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

1764 ) 

1765 return 

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

1767 call_from_thread(self, self._send_message, question) 

1768 

1769 def _consume_stream( 

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

1771 ) -> None: 

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

1773 worker = _get_worker() 

1774 reason_buf: list[str] = [] 

1775 content_buf: list[str] = [] 

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

1777 

1778 def flush() -> None: 

1779 if reason_buf: 

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

1781 reason_buf.clear() 

1782 if content_buf: 

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

1784 content_buf.clear() 

1785 

1786 for token in stream: 

1787 if worker.is_cancelled: 

1788 break 

1789 try: 

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

1791 self._maybe_flush(flush, timings) 

1792 except Exception: 

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

1794 with contextlib.suppress(Exception): 

1795 flush() 

1796 

1797 @staticmethod 

1798 def _buffer_token( 

1799 token: Any, 

1800 reason_buf: list[str], 

1801 content_buf: list[str], 

1802 response_parts: list[str], 

1803 ) -> None: 

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

1805 if token.is_reasoning: 

1806 reason_buf.append(token.content) 

1807 elif token.content: 

1808 response_parts.append(token.content) 

1809 content_buf.append(token.content) 

1810 

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

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

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

1814 """ 

1815 now = time.monotonic() 

1816 if now - timings.last_flush >= _STREAM_FLUSH_INTERVAL: 

1817 flush() 

1818 timings.last_flush = now 

1819 

1820 def _finalize_stream( 

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

1822 ) -> None: 

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

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

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

1826 call_from_thread(self, self._set_streaming, False) 

1827 full_response = "".join(response_parts) 

1828 if full_response: 

1829 with self._history_lock: 

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

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

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

1833 self._persist_assistant_turn(full_response, sources) 

1834 call_from_thread(self, self._refresh_context_usage) 

1835 call_from_thread(self, widget.finish, sources) 

1836 if ( 

1837 cfg.chat_mode == ChatMode.SEARCH.value 

1838 and self._embedding_ready() 

1839 and full_response 

1840 and SOURCES_BLOCK_MARKER not in full_response 

1841 ): 

1842 call_from_thread(self, self._notify_no_results) 

1843 

1844 def _notify_no_results(self) -> None: 

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

1846 

1847 @staticmethod 

1848 def _history_budget() -> int: 

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

1850 return history_budget(cfg.chat_n_ctx_target) 

1851 

1852 def _compact_history(self) -> None: 

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

1854 

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

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

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

1858 

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

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

1861 user sends another turn meanwhile. 

1862 """ 

1863 with self._history_lock: 

1864 history = list(self._history) 

1865 summary = self._summary 

1866 budget = self._history_budget() 

1867 if not cfg.chat_compaction: 

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

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

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

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

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

1873 if not dropped: 

1874 return 

1875 with self._history_lock: 

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

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

1878 return 

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

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

1881 return 

1882 dropped = foldable(history) 

1883 if not dropped: 

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

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

1886 # it instead. 

1887 return 

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

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

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

1891 call_from_thread(self, self._set_compacting, True) 

1892 try: 

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

1894 finally: 

1895 call_from_thread(self, self._set_compacting, False) 

1896 with self._history_lock: 

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

1898 self._summary = result.summary 

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

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

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

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

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

1904 with contextlib.suppress(SessionNotFoundError): 

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

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

1907 

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

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

1910 with contextlib.suppress(NoMatches): 

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

1912 

1913 def _refresh_context_usage(self) -> None: 

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

1915 

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

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

1918 """ 

1919 with self._history_lock: 

1920 history = list(self._history) 

1921 summary = self._summary 

1922 budget = self._history_budget() 

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

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

1925 with contextlib.suppress(NoMatches): 

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

1927 

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

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

1930 

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

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

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

1934 user has navigated off the chat screen. 

1935 """ 

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

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

1938 anchor = self._active_question 

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

1940 anchor = None 

1941 with contextlib.suppress(NoMatches): 

1942 for title in titles: 

1943 rule = Static( 

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

1945 classes="compaction-marker", 

1946 ) 

1947 if anchor is None: 

1948 self._chat_log.mount(rule) 

1949 else: 

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

1951 

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

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

1954 

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

1956 consistently, different words because nothing was summarized. 

1957 """ 

1958 self._refresh_context_usage() 

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

1960 with contextlib.suppress(NoMatches): 

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

1962 

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

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

1965 

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

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

1968 

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

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

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

1972 """ 

1973 self._refresh_context_usage() 

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

1975 if stranded: 

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

1977 self._mark_context_boundary(*titles) 

1978 with contextlib.suppress(NoMatches): 

1979 self.notify( 

1980 msg.CHAT_COMPACTED_STRANDED_TOAST if stranded else msg.CHAT_COMPACTED_TOAST, 

1981 severity="warning", 

1982 ) 

1983 

1984 def action_scroll_up(self) -> None: 

1985 self._chat_log.scroll_page_up() 

1986 

1987 def action_scroll_down(self) -> None: 

1988 self._chat_log.scroll_page_down() 

1989 

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

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

1992 

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

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

1995 """ 

1996 if action == "cancel_stream": 

1997 return self.streaming and self._insert_mode 

1998 if action == "enter_model_strip": 

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

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

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

2002 # letters without having to be named here. 

2003 focused = self.focused 

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

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

2006 

2007 def action_enter_normal_mode(self) -> None: 

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

2009 overlay = self._completion_overlay 

2010 if overlay.is_visible: 

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

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

2013 self._completion_origin 

2014 ): 

2015 self._set_input(self._completion_origin) 

2016 self._completion_origin = None 

2017 overlay.hide() 

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

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

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

2021 self._set_input("") 

2022 return 

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

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

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

2026 # makes sure can_focus is re-enabled. 

2027 self._enter_insert_mode() 

2028 return 

2029 self._insert_mode = False 

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

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

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

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

2034 self._chat_input.can_focus = False 

2035 self._chat_log.focus() 

2036 self._update_input_style() 

2037 

2038 def action_cancel_stream(self) -> None: 

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

2040 if self.streaming: 

2041 self._cancel_inflight_stream(msg.STREAM_CANCELLED) 

2042 

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

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

2045 

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

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

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

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

2050 user waits for an answer that will never arrive. 

2051 """ 

2052 for worker in self.workers: 

2053 worker.cancel() 

2054 get_services().cancel_inference() 

2055 bubble = self._active_assistant 

2056 if bubble is not None and bubble.is_mounted: 

2057 bubble.append_content(note) 

2058 self.streaming = False 

2059 

2060 def apply_model_change(self) -> None: 

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

2062 

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

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

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

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

2067 once without waiting for other workers. 

2068 

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

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

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

2072 and cleared answer alike. 

2073 """ 

2074 if self.swapping_model: 

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

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

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

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

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

2080 return 

2081 if self.streaming: 

2082 from lilbee.catalog.formatting import display_label_for_ref 

2083 

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

2085 # extra state. 

2086 self._model_switch_queued = True 

2087 self.app.notify( 

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

2089 ) 

2090 return 

2091 self.swapping_model = True 

2092 self.app.notify(msg.MODEL_SWAP_APPLYING) 

2093 self._reload_chat_model_worker() 

2094 

2095 def _apply_input_busy_state(self) -> None: 

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

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

2098 with no explanation. 

2099 

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

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

2102 under them. Guarded because the unblock can fire (via ``call_from_thread`` 

2103 or a bubbled message) after the user navigated away and the input is no 

2104 longer mounted. 

2105 """ 

2106 no_model = not self.app.chat_is_ready 

2107 busy = self.swapping_model or self.reloading_placement or no_model 

2108 with contextlib.suppress(NoMatches): 

2109 inp = self._chat_input 

2110 inp.disabled = busy 

2111 if no_model: 

2112 inp.placeholder = msg.CHAT_INPUT_NO_MODEL 

2113 elif self.swapping_model: 

2114 from lilbee.catalog.formatting import display_label_for_ref 

2115 

2116 inp.placeholder = msg.CHAT_INPUT_SWITCHING.format( 

2117 name=display_label_for_ref(cfg.chat_model) 

2118 ) 

2119 elif self.reloading_placement: 

2120 inp.placeholder = msg.CHAT_INPUT_RELOADING 

2121 else: 

2122 inp.placeholder = msg.CHAT_INPUT_PLACEHOLDER_DEFAULT 

2123 if not busy and self._insert_mode: 

2124 inp.focus() 

2125 

2126 def watch_swapping_model(self, swapping: bool) -> None: 

2127 self._apply_input_busy_state() 

2128 

2129 def watch_reloading_placement(self, reloading: bool) -> None: 

2130 self._apply_input_busy_state() 

2131 

2132 def on_fleet_body_placement_reloading(self, event: FleetBody.PlacementReloading) -> None: 

2133 """Hold chat submissions while the Fleet drawer reloads the fleet.""" 

2134 self.reloading_placement = event.active 

2135 

2136 @work(thread=True, name=_MODEL_SWAP_WORKER, exit_on_error=False) 

2137 def _reload_chat_model_worker(self) -> None: 

2138 """Reload the chat role and warm the new model before unblocking the input. 

2139 

2140 ``reload_role(wait=True)`` re-plans and restarts the fleet for the new chat 

2141 model (retrieval is untouched) and returns once the proxy is back up. The 

2142 model is then warmed here rather than deferred to the user's next prompt: 

2143 ``request_engine_warm`` drives the load and populates the provider warm 

2144 tracker, which the task-bar footer renders (spinner, model, phase), and 

2145 ``wait_chat_ready`` holds the input disabled until the model actually 

2146 serves -- so the switch never hands back a live input in front of a model 

2147 that has not loaded. The provider serializes overlapping reloads, so a 

2148 rapid second swap coalesces onto the latest cfg. 

2149 """ 

2150 from lilbee.app.placement import ( 

2151 chat_warm_error, 

2152 request_engine_warm, 

2153 wait_chat_ready, 

2154 ) 

2155 

2156 worker = _get_worker() 

2157 try: 

2158 get_services().reload_role(WorkerRole.CHAT, wait=True) 

2159 request_engine_warm() 

2160 ready = wait_chat_ready(should_abort=lambda: worker.is_cancelled) 

2161 except Exception as exc: # any reload failure becomes a toast, never a crash 

2162 call_from_thread(self, self._on_model_swap_failed, str(exc)) 

2163 return 

2164 if worker.is_cancelled: 

2165 return 

2166 error = None if ready else chat_warm_error() 

2167 if error: 

2168 call_from_thread(self, self._on_model_swap_failed, error) 

2169 else: 

2170 call_from_thread(self, self._on_model_swapped) 

2171 

2172 def _on_model_swapped(self) -> None: 

2173 """Main-thread completion: unblock the input and confirm the new model.""" 

2174 from lilbee.catalog.formatting import display_label_for_ref 

2175 

2176 self.swapping_model = False 

2177 self.app.notify(msg.MODEL_SWAP_DONE.format(name=display_label_for_ref(cfg.chat_model))) 

2178 

2179 def _on_model_swap_failed(self, error: str) -> None: 

2180 """Main-thread failure: unblock the input and surface the error.""" 

2181 self.swapping_model = False 

2182 self.app.notify(msg.MODEL_SWAP_FAILED.format(error=error), severity="error") 

2183 

2184 @on(Markdown.LinkClicked) 

2185 def _open_answer_link(self, event: Markdown.LinkClicked) -> None: 

2186 """Open a link clicked in an answer: ``file:`` citations open in the OS 

2187 default app for the file type; web links open in the browser.""" 

2188 event.stop() 

2189 if event.href.startswith("file://"): 

2190 open_local_file(event.href) 

2191 else: 

2192 self.app.open_url(event.href) 

2193 

2194 async def action_toggle_markdown(self) -> None: 

2195 """Toggle between Markdown and plain-text rendering for chat responses.""" 

2196 cfg.markdown_rendering = not cfg.markdown_rendering 

2197 use_md = cfg.markdown_rendering 

2198 chat_log = self._chat_log 

2199 for widget in chat_log.query(AssistantMessage): 

2200 await widget.rebuild_content_widget(use_md) 

2201 label = "Markdown" if use_md else "Plain text" 

2202 self.notify(msg.CHAT_RENDERING.format(label=label)) 

2203 

2204 def _run_sync(self, *, force_rebuild: bool = False) -> None: 

2205 """Enqueue a document sync (or full rebuild) in the task bar.""" 

2206 if self._sync_active: 

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

2208 return 

2209 from lilbee.cli.tui.task_queue import TaskType 

2210 

2211 self._sync_active = True 

2212 # Clear the pending hint so the bar shows live sync progress 

2213 # instead of the stale "N docs to sync" line. 

2214 self._task_bar.clear_pending_sync() 

2215 

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

2217 try: 

2218 self._do_sync(reporter, force_rebuild=force_rebuild) 

2219 finally: 

2220 self._sync_active = False 

2221 # Re-detect after every sync attempt: success drives the 

2222 # count to 0, failure or cancel leaves the still-pending 

2223 # files counted so the hint reappears. 

2224 self._task_bar.start_detect_pending() 

2225 

2226 label = msg.TASK_NAME_REBUILD if force_rebuild else msg.TASK_NAME_SYNC 

2227 self._task_bar.start_task(label, TaskType.SYNC, _target, indeterminate=True) 

2228 

2229 def _do_sync(self, reporter: ProgressReporter, *, force_rebuild: bool = False) -> None: 

2230 """Sync body. Runs on worker thread.""" 

2231 from lilbee.data.ingest import sync 

2232 

2233 reporter.update(0, msg.SYNC_STATUS_SYNCING, indeterminate=True) 

2234 on_progress = build_sync_progress_callback(reporter) 

2235 try: 

2236 result = asyncio_loop.run( 

2237 sync(quiet=True, on_progress=on_progress, force_rebuild=force_rebuild) 

2238 ) 

2239 except asyncio.CancelledError as exc: 

2240 raise RuntimeError(msg.SYNC_CANCELLED_RESUME) from exc 

2241 if result.failed: 

2242 raise RuntimeError(msg.SYNC_FAILED_FILES.format(files=", ".join(result.failed))) 

2243 if result.skipped: 

2244 call_from_thread( 

2245 self, 

2246 self.notify, 

2247 msg.sync_skipped_message(", ".join(result.skipped)), 

2248 severity="warning", 

2249 ) 

2250 

2251 def action_focus_commands(self) -> None: 

2252 """Focus chat input and pre-fill with '/' for command entry.""" 

2253 # Route through the helper so can_focus is re-enabled when this 

2254 # action fires from NORMAL mode; bare ``inp.focus()`` would 

2255 # silently no-op while the input is intentionally unfocusable. 

2256 self._enter_insert_mode() 

2257 inp = self._chat_input 

2258 if not inp.value.startswith("/"): 

2259 inp.value = "/" 

2260 inp.action_end() 

2261 

2262 def action_toggle_chat_mode(self) -> None: 

2263 """F3: flip between Search and Chat mode.""" 

2264 try: 

2265 toggle = self.query_one(ChatModeToggle) 

2266 except NoMatches: 

2267 return 

2268 if not toggle.toggle(): 

2269 return 

2270 label = ( 

2271 msg.CHAT_MODE_SEARCH_LABEL 

2272 if cfg.chat_mode == ChatMode.SEARCH.value 

2273 else msg.CHAT_MODE_CHAT_LABEL 

2274 ) 

2275 self.notify(msg.CHAT_MODE_SET.format(label=label)) 

2276 

2277 def action_cycle_scope(self) -> None: 

2278 """``s``: cycle the scope chip when it is currently visible.""" 

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

2280 

2281 try: 

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

2283 except NoMatches: 

2284 return 

2285 if chip.has_class("-hidden"): 

2286 return 

2287 chip.cycle_scope() 

2288 

2289 def action_complete(self) -> None: 

2290 """Tab: fill the shared prefix, then cycle matches (readline / vim style). 

2291 

2292 - Insert mode + chat input focused + dropdown closed but matches 

2293 exist: open it, fill the longest common prefix, else preview the 

2294 first match. 

2295 - Insert mode + chat input focused + dropdown open: fill any further 

2296 shared prefix, otherwise preview the next match. 

2297 - Insert mode + chat input focused + no matches: insert ``\\t`` so 

2298 users can type tab characters directly. 

2299 - Normal mode or focus elsewhere: advance through the focus 

2300 chain so Tab still walks every focusable widget. 

2301 """ 

2302 inp = self._chat_input 

2303 if not self._insert_mode or not inp.has_focus: 

2304 self._tab_into_fleet_or_next() 

2305 return 

2306 overlay = self._completion_overlay 

2307 if not overlay.is_visible and not self._open_completions(): 

2308 inp.insert("\t") 

2309 return 

2310 if self._fill_common_prefix(): 

2311 return 

2312 self._preview_next() 

2313 

2314 def _focus_in_drawer(self) -> bool: 

2315 """True when keyboard focus is inside an open drawer, so Enter / i / a / o 

2316 reach that drawer's own controls instead of entering insert mode. 

2317 

2318 Asked of the Drawer base rather than one drawer class: a drawer that had 

2319 to name itself here would otherwise swallow its own Enter until someone 

2320 noticed. 

2321 """ 

2322 focused = self.focused 

2323 return bool(focused and any(isinstance(n, Drawer) for n in focused.ancestors_with_self)) 

2324 

2325 def _focus_in_model_bar(self) -> bool: 

2326 """True when focus is on any model-strip member. 

2327 

2328 Asked of the container rather than of each member class so a member 

2329 added later is covered without a second edit here. 

2330 """ 

2331 focused = self.focused 

2332 return bool(focused and any(isinstance(n, ModelBar) for n in focused.ancestors_with_self)) 

2333 

2334 def _tab_into_fleet_or_next(self) -> None: 

2335 """Jump Tab into the open Fleet drawer's first toggle so the placement 

2336 editor is reachable without tabbing past every widget; once focus is 

2337 inside the drawer, Tab cycles within it as usual.""" 

2338 drawers = self.screen.query(FleetDrawer) 

2339 if not drawers: 

2340 self.screen.focus_next() 

2341 return 

2342 drawer = drawers.first() 

2343 focused = self.screen.focused 

2344 inside = focused is not None and drawer in focused.ancestors_with_self 

2345 toggles = drawer.query(".dev-toggle") 

2346 if not inside and toggles: 

2347 toggles.first().focus() 

2348 return 

2349 self.screen.focus_next() 

2350 

2351 def action_complete_next(self) -> None: 

2352 """Ctrl+N: preview the next match, opening the dropdown if it is closed (vim ``<C-n>``).""" 

2353 if not self._chat_input.has_focus: 

2354 # Not a completion here; skip so an open overlay (e.g. the sessions 

2355 # drawer) can bind Ctrl+N instead of this priority binding eating it. 

2356 raise SkipAction() 

2357 if self._completion_overlay.is_visible or self._open_completions(): 

2358 self._preview_next() 

2359 

2360 def action_complete_prev(self) -> None: 

2361 """Ctrl+P: preview the previous match, opening the dropdown if it is closed.""" 

2362 if not self._chat_input.has_focus: 

2363 return 

2364 if self._completion_overlay.is_visible or self._open_completions(): 

2365 self._preview_prev() 

2366 

2367 def _preview_next(self) -> None: 

2368 """Preview the highlighted match if none is previewed yet, else step forward.""" 

2369 overlay = self._completion_overlay 

2370 if self._chat_input.value == self._completion_origin: 

2371 display = overlay.get_current() 

2372 else: 

2373 display = overlay.cycle_next() 

2374 if display is not None: 

2375 self._preview_completion(display) 

2376 

2377 def _preview_prev(self) -> None: 

2378 """Step the highlight backward (wrapping to the last match) and preview it.""" 

2379 display = self._completion_overlay.cycle_prev() 

2380 if display is not None: 

2381 self._preview_completion(display) 

2382 

2383 def _open_completions(self) -> bool: 

2384 """Show the dropdown for the current input and remember it as the origin.""" 

2385 options = get_completions(self._chat_input.value) 

2386 if not options: 

2387 return False 

2388 self._completion_origin = self._chat_input.value 

2389 self._completion_overlay.show_completions(options) 

2390 return True 

2391 

2392 def _completion_value(self, display: str) -> str: 

2393 """Full input text produced by accepting ``display``, keeping the typed prefix. 

2394 

2395 Path completions are basenames, so the directory the user already 

2396 typed (``~/``, ``./``, absolute) is preserved and only the final 

2397 segment is replaced. 

2398 """ 

2399 text = ( 

2400 self._completion_origin 

2401 if self._completion_origin is not None 

2402 else (self._chat_input.value) 

2403 ) 

2404 if " " not in text: 

2405 return display 

2406 cmd, _, partial = text.partition(" ") 

2407 if cmd.lower() in PATH_ARG_COMMANDS: 

2408 head = path_completion_prefix(partial) 

2409 return f"{cmd} {head}{display}" 

2410 return f"{cmd} {display}" 

2411 

2412 def _set_input(self, value: str) -> None: 

2413 """Replace the input value without triggering the live-refresh of the dropdown.""" 

2414 inp = self._chat_input 

2415 if inp.value == value: 

2416 return 

2417 # The setter posts Changed asynchronously; flag one event to ignore so 

2418 # the previewed candidate doesn't re-filter (and collapse) the dropdown. 

2419 # (The value setter already moves the cursor to the end.) 

2420 self._suppress_refresh += 1 

2421 inp.value = value 

2422 

2423 def _preview_completion(self, display: str) -> None: 

2424 """Write the highlighted candidate into the input, leaving the dropdown open.""" 

2425 self._set_input(self._completion_value(display)) 

2426 

2427 def _fill_common_prefix(self) -> bool: 

2428 """Extend the input to the longest prefix shared by all matches; True if it grew.""" 

2429 overlay = self._completion_overlay 

2430 values = [self._completion_value(d) for d in overlay.options] 

2431 shared = longest_common_prefix(values) 

2432 if len(shared) <= len(self._chat_input.value): 

2433 return False 

2434 self._set_input(shared) 

2435 # Re-filter for the newly completed prefix (descends into a directory, 

2436 # narrows the model list, etc.). 

2437 self._refresh_completion_overlay() 

2438 return True 

2439 

2440 def action_history_prev(self) -> None: 

2441 """Up arrow: cycle the dropdown if visible, else recall previous history entry.""" 

2442 if not self._insert_mode: 

2443 raise SkipAction() 

2444 inp = self._chat_input 

2445 if not inp.has_focus: 

2446 raise SkipAction() 

2447 # When the completion dropdown is up, Up navigates the dropdown 

2448 # (vim/Emacs-style) rather than recalling history. 

2449 overlay = self._completion_overlay 

2450 if overlay.is_visible: 

2451 self._preview_prev() 

2452 return 

2453 if not self._input_history: 

2454 raise SkipAction() 

2455 if self._history_index == -1: 

2456 self._history_index = len(self._input_history) - 1 

2457 elif self._history_index > 0: 

2458 self._history_index -= 1 

2459 else: 

2460 return 

2461 inp.value = self._input_history[self._history_index] 

2462 inp.action_end() 

2463 

2464 def action_history_next(self) -> None: 

2465 """Down arrow: cycle the dropdown if visible, else recall next history entry.""" 

2466 if not self._insert_mode: 

2467 raise SkipAction() 

2468 inp = self._chat_input 

2469 if not inp.has_focus: 

2470 raise SkipAction() 

2471 # When the completion dropdown is up, Down navigates the dropdown. 

2472 overlay = self._completion_overlay 

2473 if overlay.is_visible: 

2474 self._preview_next() 

2475 return 

2476 if self._history_index == -1: 

2477 raise SkipAction() 

2478 if self._history_index < len(self._input_history) - 1: 

2479 self._history_index += 1 

2480 inp.value = self._input_history[self._history_index] 

2481 inp.action_end() 

2482 else: 

2483 self._history_index = -1 

2484 inp.value = "" 

2485 

2486 @on(ChatInput.Changed, "#chat-input") 

2487 def _on_chat_input_changed(self, event: ChatInput.Changed) -> None: 

2488 """Refresh arg-hint and auto-show or hide the completion dropdown.""" 

2489 if self._suppress_refresh > 0: 

2490 # A programmatic edit (preview / accept / revert) is managing the 

2491 # overlay itself; consume one Changed and skip the live refresh. 

2492 self._suppress_refresh -= 1 

2493 self._refresh_arg_hint() 

2494 return 

2495 self._refresh_completion_overlay() 

2496 self._refresh_arg_hint() 

2497 

2498 def _refresh_completion_overlay(self) -> None: 

2499 """Live-filter the dropdown against the current input, in command and arg modes alike.""" 

2500 overlay = self._completion_overlay 

2501 text = self._chat_input.value 

2502 options = get_completions(text) 

2503 if options: 

2504 self._completion_origin = text 

2505 overlay.show_completions(options) 

2506 elif overlay.is_visible: 

2507 overlay.hide() 

2508 self._completion_origin = None 

2509 

2510 def _refresh_arg_hint(self) -> None: 

2511 """Push the current input value into the ArgHintLine.""" 

2512 self._arg_hint.update_for_input(self._chat_input.value) 

2513 

2514 def refresh_model_bar(self) -> None: 

2515 """Re-scan installed models and refresh the model bar. 

2516 

2517 Show can arrive before the prompt area's descendants have mounted, and 

2518 on a slow runner it does. A bar that is not in the DOM yet scans on its 

2519 own mount, so a missing bar means the scan is already coming, not that 

2520 it was skipped -- the re-scan here is what re-entering the screen needs. 

2521 Querying regardless raised NoMatches out of the show handler, which 

2522 Textual re-raises as an app crash. 

2523 """ 

2524 for bar in self.query("#model-bar").results(ModelBar): 

2525 bar.refresh_models() 

2526 

2527 def action_vim_scroll_down(self) -> None: 

2528 """Vim j: scroll down in normal mode.""" 

2529 if self._insert_mode: 

2530 raise SkipAction() 

2531 self._chat_log.scroll_down() 

2532 

2533 def action_vim_scroll_up(self) -> None: 

2534 """Vim k: scroll up in normal mode.""" 

2535 if self._insert_mode: 

2536 raise SkipAction() 

2537 self._chat_log.scroll_up() 

2538 

2539 def action_vim_scroll_home(self) -> None: 

2540 """Vim g: scroll to top in normal mode.""" 

2541 if self._insert_mode: 

2542 raise SkipAction() 

2543 self._chat_log.scroll_home() 

2544 

2545 def action_vim_scroll_end(self) -> None: 

2546 """Vim G: scroll to bottom in normal mode.""" 

2547 if self._insert_mode: 

2548 raise SkipAction() 

2549 self._chat_log.scroll_end() 

2550 

2551 def action_half_page_down(self) -> None: 

2552 """Ctrl-D: half-page down (vim style).""" 

2553 log_widget = self._chat_log 

2554 half = max(1, log_widget.size.height // 2) 

2555 log_widget.scroll_relative(y=half) 

2556 

2557 def action_half_page_up(self) -> None: 

2558 """Ctrl-U: half-page up (vim style).""" 

2559 log_widget = self._chat_log 

2560 half = max(1, log_widget.size.height // 2) 

2561 log_widget.scroll_relative(y=-half)