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

1734 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +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, field 

15from functools import partial 

16from pathlib import Path 

17from typing import TYPE_CHECKING, Any, ClassVar 

18 

19from pydantic import ValidationError 

20from rich.rule import Rule 

21from textual import events, getters, on, work 

22from textual.actions import SkipAction 

23from textual.app import ComposeResult 

24from textual.binding import Binding, BindingType 

25from textual.containers import Horizontal, Vertical, VerticalScroll 

26from textual.content import Content 

27from textual.css.query import NoMatches 

28from textual.dom import DOMNode 

29from textual.message import Message 

30from textual.reactive import reactive 

31from textual.screen import Screen 

32from textual.widget import Widget 

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

34 

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

36# since it's used in multiple methods. 

37from textual.worker import NoActiveWorker 

38from textual.worker import get_current_worker as _get_worker 

39 

40from lilbee.app.analyze import hide_tip 

41from lilbee.app.ingest import ( 

42 LIBRARY_REFUSALS, 

43 AddRollback, 

44 RegisterResult, 

45 forget_unfinished_on_cancel, 

46 leave_as_cancel, 

47 removable_names, 

48 remove_documents_durably, 

49) 

50from lilbee.app.services import get_services, reset_store 

51from lilbee.app.session_export import write_session_markdown 

52from lilbee.app.session_sources import message_sources 

53from lilbee.app.settings import list_setting_value 

54from lilbee.app.settings_map import SETTINGS_MAP, SettingDef 

55from lilbee.app.themes import DARK_THEMES 

56from lilbee.app.version import get_version 

57from lilbee.cli.tui import messages as msg 

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

59from lilbee.cli.tui.command_registry import ( 

60 ANALYZE_OFF_ARG, 

61 ANALYZE_REPORT_ARG, 

62 runs_while_streaming, 

63) 

64from lilbee.cli.tui.log_routing import tui_log_path 

65from lilbee.cli.tui.screens.analyze_report import open_report, start_analysis 

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

67 add_indexed_anything, 

68 build_add_progress_callback, 

69 build_import_progress_callback, 

70 build_sync_progress_callback, 

71 close_stream, 

72 open_local_file, 

73 remember_from_input, 

74 unregister_added_roots, 

75) 

76from lilbee.cli.tui.screens.profile_dialogs import run_profile_op, start_profile_switch 

77from lilbee.cli.tui.thread_safe import call_from_thread, post_from_thread 

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

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

80 PATH_ARG_COMMANDS, 

81 CompletionOverlay, 

82 get_completions, 

83 list_model_options, 

84 longest_common_prefix, 

85 model_completions, 

86 path_completion_prefix, 

87 reads_a_folder, 

88 reads_models, 

89) 

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

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

92from lilbee.cli.tui.widgets.drawer import drawer_holding, first_direct_child 

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

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

95from lilbee.cli.tui.widgets.fork_picker import ForkPicker, fork_points 

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

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

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

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

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

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

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

103from lilbee.core.config import cfg 

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

105from lilbee.core.config.model import CLEARABLE_MODEL_FIELDS 

106from lilbee.core.text import strip_control_characters 

107from lilbee.crawler import ( 

108 CRAWLER_MISSING_MESSAGE, 

109 crawler_available, 

110 is_url, 

111 require_valid_crawl_url, 

112) 

113from lilbee.crawler.events import BROWSER_SETUP_COMPONENT 

114from lilbee.crawler.models import NO_LOGIN, CrawlLogin, crawl_limit_error 

115from lilbee.crawler.stored_login import crawl_login 

116from lilbee.crawler.url_filter import without_userinfo 

117from lilbee.data.store import ( 

118 ChunkType, 

119 EmbeddingModelMismatchError, 

120 SearchScope, 

121 scope_to_chunk_type, 

122) 

123from lilbee.providers.roles import WorkerRole 

124from lilbee.providers.warm_progress import WarmPhase, WarmProgress 

125from lilbee.retrieval.embedder import is_model_available 

126from lilbee.retrieval.query import ChatMessage 

127from lilbee.retrieval.query.compaction import ( 

128 compaction_due, 

129 foldable, 

130 history_budget, 

131 overflow, 

132 prompt_history, 

133 summary_messages, 

134) 

135from lilbee.retrieval.query.formatting import AnswerSources, SourceRef 

136from lilbee.retrieval.query.history_window import estimate_tokens 

137from lilbee.retrieval.reasoning import RetrievalNotice 

138from lilbee.runtime import asyncio_loop 

139from lilbee.runtime.cancellation import TaskCancelledError 

140from lilbee.runtime.lock import ResetRefusedError 

141from lilbee.runtime.progress import ( 

142 EventType, 

143 ProgressEvent, 

144 SetupStartEvent, 

145) 

146from lilbee.sessions import ( 

147 MessageRole, 

148 Session, 

149 SessionMessage, 

150 SessionNotFoundError, 

151 SessionOrigin, 

152 SessionOwnershipError, 

153 SessionStore, 

154 StoredSourceRef, 

155 TitleSource, 

156 derive_title, 

157 message_with_refs, 

158) 

159 

160if TYPE_CHECKING: 

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

162 from lilbee.data.types import SyncResult 

163log = logging.getLogger(__name__) 

164 

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

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

167# message queue and makes key events visibly lag. 

168_STREAM_FLUSH_INTERVAL = 0.05 

169 

170 

171@dataclass 

172class _StreamTimings: 

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

174 

175 last_flush: float 

176 

177 

178@dataclass 

179class _Turn: 

180 """One live question: its stop signal, answer bubble, conversation and session.""" 

181 

182 question: str 

183 widget: AssistantMessage 

184 generation: int 

185 session_id: str | None 

186 stop: threading.Event = field(default_factory=threading.Event) 

187 answer: str = "" 

188 # The answer's Sources list, kept out of ``answer``. 

189 source_refs: list[SourceRef] = field(default_factory=list) 

190 

191 

192class _TurnEnded(Message): 

193 """Posted by a turn's worker once its body has ended, whether or not the answer saved.""" 

194 

195 bubble = False 

196 

197 def __init__(self, turn: _Turn) -> None: 

198 super().__init__() 

199 self.turn = turn 

200 

201 

202# The stream worker's group. Only Textual's teardown cancels it: a turn stops 

203# through its stop signal, so its body always runs and always ends the turn. 

204_TURN_WORKER_GROUP = "chat_turn" 

205_SYNC_FAILED_TOAST_SECONDS = 8.0 

206 

207 

208# ``/crawl`` command flags. 

209_CRAWL_FLAG_DEPTH = "--depth" 

210_CRAWL_FLAG_MAX_PAGES = "--max-pages" 

211_CRAWL_FLAG_INCLUDE_SUBDOMAINS = "--include-subdomains" 

212_CRAWL_FLAG_RENDER = "--render" 

213 

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

215# event loop. 

216_MODEL_SWAP_WORKER = "model_swap_reset" 

217 

218# The NORMAL / INSERT mode keys (esc, i, a, o, enter). A focused drawer keeps them. 

219_MODE_ACTIONS = frozenset({"enter_normal_mode", "insert_mode", "insert_or_send"}) 

220 

221 

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

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

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

225 from lilbee.catalog.formatting import display_label_for_ref 

226 

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

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

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

230 if snapshot.phase is WarmPhase.LOADING_ENGINE: 

231 return msg.ENGINE_ALMOST_READY 

232 return msg.ENGINE_WARMING 

233 

234 

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

236 

237 

238def _stream_error_text(exc: Exception) -> str: 

239 """The note for a failed answer; a severed engine socket names the problem, not the OS error.""" 

240 if isinstance(exc, ConnectionError): 

241 return msg.STREAM_DISCONNECTED 

242 return msg.STREAM_ERROR.format(error=exc) 

243 

244 

245def _set_echo(key: str, parsed: object, *, secret: bool, show_stored: bool) -> object: 

246 """What /set shows after a write: a bool or list setting shows what it stored.""" 

247 if secret and parsed: 

248 return msg.MASKED_VALUE 

249 if not show_stored: 

250 return parsed 

251 stored = getattr(cfg, key) 

252 if stored is None: 

253 return msg.CMD_SET_AUTO 

254 return stored if stored != [] else msg.CMD_SET_EMPTY_LIST 

255 

256 

257def _refusal_text(exc: ValueError | TypeError) -> str: 

258 """Why a write refused, as one short sentence: a validator's own message, else the text.""" 

259 if isinstance(exc, ValidationError): 

260 return str(exc.errors()[0]["msg"]).removeprefix("Value error, ") 

261 return str(exc) 

262 

263 

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

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

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

267 

268 

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

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

271 low = name.lower() 

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

273 if len(contains) == 1: 

274 return contains[0] 

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

276 return matches[0] if matches else None 

277 

278 

279def _whole_path(args: str) -> Path: 

280 """The whole argument as one path, with surrounding quotes removed and ``~`` expanded.""" 

281 return Path(args.strip().strip('"').strip("'")).expanduser() 

282 

283 

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

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

286 

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

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

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

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

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

292 """ 

293 whole = _whole_path(args) 

294 if whole.exists(): 

295 return [whole] 

296 try: 

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

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

299 except ValueError: 

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

301 if os.name == "nt": 

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

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

304 

305 

306class ChatWelcome(Static): 

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

308 

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

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

311 

312 @staticmethod 

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

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

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

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

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

318 

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

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

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

322 self.update(self._body(hint)) 

323 

324 

325class PromptArea(Vertical): 

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

327 

328 pass 

329 

330 

331class ChatScreen(Screen[None]): 

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

333 

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

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

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

337 app: LilbeeApp # type: ignore[assignment] 

338 

339 CSS_PATH = "chat.tcss" 

340 AUTO_FOCUS = "#chat-input" 

341 

342 # True from the start of a turn until its worker has ended it, so it also 

343 # covers a stopped turn whose body is still unwinding. 

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

345 # True once the live turn was asked to stop; hides the Ctrl+C cancel. 

346 stopping: reactive[bool] = reactive(False) 

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

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

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

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

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

352 # holds chat submissions so they don't race the reload. 

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

354 

355 HELP = ( 

356 "# Chat\n\n" 

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

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

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

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

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

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

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

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

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

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

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

368 ) 

369 

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

371 

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

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

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

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

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

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

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

379 

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

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

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

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

384 # F2 opens the searchable list of every slash command 

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

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

387 Binding( 

388 "f2", 

389 "show_command_catalog", 

390 "All commands", 

391 show=False, 

392 priority=True, 

393 ), 

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

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

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

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

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

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

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

401 # LilbeeApp.action_command_palette overriding to call 

402 # ChatScreen.action_complete_prev. Action is exposed for direct 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

425 # check_action below hides + disables it outside that exact 

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

427 # nothing is streaming. 

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

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

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

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

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

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

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

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

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

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

438 # whole of hjkl: the transcript owns j / k for scrolling. The NORMAL 

439 # mode keys that move focus (h, l, i, a, o and Enter) are priority 

440 # bindings, so focus moves in the key's own step and the keys typed 

441 # after them follow it. 

442 Binding("h", "enter_model_strip(-1)", "Prev role", show=False, priority=True), 

443 Binding("l", "enter_model_strip(1)", "Next role", show=False, priority=True), 

444 Binding("i", "insert_mode", "Insert", show=False, priority=True), 

445 Binding("a", "insert_mode", "Insert", show=False, priority=True), 

446 Binding("o", "insert_mode", "Insert", show=False, priority=True), 

447 Binding("enter", "insert_or_send", "Insert", show=False, priority=True), 

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

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

450 # action_scroll_left raises SkipAction when there is nothing to scroll 

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

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

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

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

455 ] 

456 

457 def __init__(self) -> None: 

458 super().__init__() 

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

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

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

462 self._summary = "" 

463 self._history_lock = threading.Lock() 

464 # Which conversation _history holds; bumped under _history_lock whenever 

465 # it is replaced, so a turn's late fold can tell it no longer applies. 

466 self._conversation_generation = 0 

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

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

469 # fresh session. 

470 self._session_id: str | None = None 

471 # The live turn; set with the busy flag and cleared only when it ends. 

472 self._turn: _Turn | None = None 

473 self._insert_mode: bool = True 

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

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

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

477 self._suppress_refresh = 0 

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

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

480 self._completion_origin: str | None = None 

481 # The installed models, listed once while the input holds a model 

482 # argument (/model, /remove) and dropped when it no longer does. 

483 self._model_names: list[str] | None = None 

484 self._listing_models = False 

485 # Bumped when the input leaves the model argument; a listing started 

486 # before the bump is stale and its result is dropped. 

487 self._model_session = 0 

488 # The latest model completion asked for while the listing runs. 

489 self._model_request: tuple[str, Callable[[bool], None] | None] = ("", None) 

490 self._rebuild_owed = False 

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

492 self._history_index: int = -1 

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

494 # has to wait out a cold engine load. 

495 self._warm_tip_shown: bool = False 

496 # The latest turn's question; a context boundary mounts above it, never 

497 # after it. Outlives its turn: the next send overwrites it and a reset 

498 # clears it. 

499 self._active_question: UserMessage | None = None 

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

501 self._model_switch_queued: bool = False 

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

503 

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

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

506 

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

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

509 """ 

510 from lilbee.cli.tui.command_registry import COMMANDS 

511 

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

513 for cmd in COMMANDS: 

514 method = getattr(self, cmd.handler) 

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

516 handlers[name] = method 

517 return handlers 

518 

519 @property 

520 def _task_bar(self) -> TaskBarController: 

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

522 return self.app.task_bar 

523 

524 def compose(self) -> ComposeResult: 

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

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

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

528 

529 with TopBars(): 

530 yield ViewTabs() 

531 yield VerticalScroll( 

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

533 id="chat-log", 

534 ) 

535 with BottomBars(): 

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

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

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

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

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

541 yield ChatInput( 

542 placeholder=msg.CHAT_INPUT_PLACEHOLDER_DEFAULT, 

543 id="chat-input", 

544 ) 

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

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

547 yield TaskBar() 

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

549 # prompt block its own row. 

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

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

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

553 yield Footer() 

554 

555 def on_unmount(self) -> None: 

556 self._task_bar.unsubscribe_work_end(self._start_owed_rebuild) 

557 

558 def on_mount(self) -> None: 

559 self._task_bar.subscribe_work_end(self._start_owed_rebuild) 

560 self._update_input_style() 

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

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

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

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

565 

566 def on_show(self) -> None: 

567 """Called when screen becomes visible.""" 

568 from lilbee.runtime.splash import dismiss 

569 

570 dismiss() 

571 self.refresh_model_bar() 

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

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

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

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

576 # so global bindings keep firing). 

577 with contextlib.suppress(Exception): 

578 if self._insert_mode: 

579 self._enter_insert_mode() 

580 else: 

581 self.set_focus(self._chat_log) 

582 typed, self.app.type_ahead = self.app.type_ahead, "" 

583 if typed: 

584 self._enter_insert_mode() 

585 self._chat_input.insert(typed) 

586 

587 def _embedding_ready(self) -> bool: 

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

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

590 

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

592 key, _value = payload 

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

594 self.refresh_model_bar() 

595 

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

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

598 with contextlib.suppress(NoMatches): 

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

600 self._apply_input_busy_state() 

601 

602 def _enter_insert_mode(self) -> None: 

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

604 self._insert_mode = True 

605 self._chat_input.can_focus = True 

606 self.set_focus(self._chat_input) 

607 self._update_input_style() 

608 

609 def focus_prompt(self) -> None: 

610 """Focus the chat input in INSERT mode so the next keys type a prompt.""" 

611 self._enter_insert_mode() 

612 

613 def default_focus_target(self) -> Widget: 

614 """This mode's own focus target: the prompt in INSERT, the transcript in NORMAL.""" 

615 return self._chat_input if self._insert_mode else self._chat_log 

616 

617 def action_focus_model_bar(self) -> None: 

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

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

620 

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

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

623 

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

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

626 """ 

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

628 

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

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

631 if self._reject_submit_when_busy(text): 

632 return 

633 self._handle_slash(text) 

634 

635 def _update_input_style(self) -> None: 

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

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

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

639 with contextlib.suppress(NoMatches): 

640 inp = self._chat_input 

641 if self._insert_mode: 

642 inp.remove_class("normal-mode") 

643 else: 

644 inp.add_class("normal-mode") 

645 self._update_mode_indicator() 

646 

647 def _update_mode_indicator(self) -> None: 

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

649 with contextlib.suppress(NoMatches): 

650 bar = self.query_one(ViewTabs) 

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

652 

653 def on_key(self, event: events.Key) -> None: 

654 """In INSERT mode, type a printable key into the prompt when focus sits elsewhere.""" 

655 inp = self._chat_input 

656 if self._insert_mode and not inp.has_focus and event.is_printable and event.character: 

657 self.set_focus(inp) 

658 inp.insert(event.character) 

659 event.prevent_default() 

660 event.stop() 

661 

662 def action_insert_mode(self) -> None: 

663 """i / a / o from NORMAL mode: back to INSERT with the prompt focused.""" 

664 self._enter_insert_mode() 

665 

666 def action_insert_or_send(self) -> None: 

667 """Enter from NORMAL mode: back to INSERT, sending the draft the user Esc'd over.""" 

668 self._enter_insert_mode() 

669 inp = self._chat_input 

670 if inp.value.strip(): 

671 self._submit_draft(inp, inp.value) 

672 

673 def _leaves_normal_mode(self, action: str) -> bool: 

674 """True when a NORMAL mode key should return to INSERT. 

675 

676 The prompt keeps its own keys. A focused Select or 

677 model-strip member keeps Enter; asked of the bar rather than of a list of 

678 widget types, so Enter on a mode pill switches the mode instead of 

679 dropping to INSERT. 

680 """ 

681 if self._insert_mode or self._chat_input.has_focus: 

682 return False 

683 if action == "insert_or_send": 

684 return not (isinstance(self.focused, Select) or self._focus_in_model_bar()) 

685 return True 

686 

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

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

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

690 

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

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

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

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

695 the screen mode here. 

696 """ 

697 if not self._insert_mode: 

698 self._enter_insert_mode() 

699 

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

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

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

703 

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

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

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

707 """ 

708 if not self._insert_mode: 

709 self._enter_insert_mode() 

710 event.stop() 

711 

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

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

714 

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

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

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

718 """ 

719 if not self._insert_mode: 

720 return 

721 if event.widget is None: 

722 return 

723 chat_input = self._chat_input 

724 node: DOMNode | None = event.widget 

725 while node is not None: 

726 if node is chat_input: 

727 return 

728 node = node.parent 

729 self.action_enter_normal_mode() 

730 

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

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

733 if not self._insert_mode: 

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

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

736 self._enter_insert_mode() 

737 return 

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

739 

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

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

742 text = value.strip() 

743 if not self._ready_to_submit(text): 

744 return 

745 chat_input.value = "" 

746 self._input_history.append(text) 

747 self._history_index = -1 

748 

749 if text.startswith("/"): 

750 self._handle_slash(text) 

751 return 

752 self._send_message(text) 

753 

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

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

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

757 return False 

758 cmd = self._slash_name(text) 

759 if cmd: 

760 if cmd not in self._command_handlers: 

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

762 # fixed in place instead of retyped. 

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

764 return False 

765 return True 

766 pending = self._pending_required_model_download() 

767 if pending is not None: 

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

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

770 self.notify( 

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

772 severity="warning", 

773 timeout=5, 

774 ) 

775 return False 

776 return True 

777 

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

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

780 

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

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

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

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

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

786 """ 

787 if self.swapping_model: 

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

789 return True 

790 if self.reloading_placement: 

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

792 return True 

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

794 self._notify_busy() 

795 return True 

796 return False 

797 

798 def _notify_busy(self) -> None: 

799 """Say why a submit was refused: the turn is answering, or still stopping.""" 

800 text = msg.CHAT_STOPPING if self.stopping else msg.CHAT_BUSY 

801 self.notify(text, severity="warning", timeout=3) 

802 

803 @staticmethod 

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

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

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

807 

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

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

810 

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

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

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

814 """ 

815 task_bar = self.app.task_bar 

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

817 label = task_bar.downloading_label_for(ref) 

818 if label is not None: 

819 return label 

820 return None 

821 

822 def _dismiss_overlay_on_submit(self) -> bool: 

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

824 

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

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

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

828 a submission. 

829 """ 

830 overlay = self._completion_overlay 

831 if overlay.is_visible: 

832 overlay.hide() 

833 self._completion_origin = None 

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

835 self._set_input("") 

836 return True 

837 return False 

838 

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

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

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

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

843 handler = self._command_handlers.get(cmd) 

844 if handler is not None: 

845 handler(args) 

846 else: 

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

848 

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

850 if streaming: 

851 self._enter_streaming_state() 

852 else: 

853 self._exit_streaming_state() 

854 

855 def watch_stopping(self, _stopping: bool) -> None: 

856 self.refresh_bindings() 

857 

858 def _enter_streaming_state(self) -> None: 

859 self.add_class("streaming") 

860 self.refresh_bindings() 

861 

862 def _exit_streaming_state(self) -> None: 

863 self.remove_class("streaming") 

864 self.refresh_bindings() 

865 if self._model_switch_queued: 

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

867 self._model_switch_queued = False 

868 self.apply_model_change() 

869 

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

871 from lilbee.app.ingest import source_label_taken 

872 from lilbee.core.system import disk_path 

873 

874 if not args: 

875 return 

876 if self._sync_work_live(): 

877 self._notify_sync_refused() 

878 return 

879 if is_url(args): 

880 self._cmd_crawl(args) 

881 return 

882 paths = _parse_add_paths(args) 

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

884 if missing: 

885 self.notify( 

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

887 severity="error", 

888 ) 

889 return 

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

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

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

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

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

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

896 if duplicates: 

897 self._prompt_overwrite(paths, duplicates) 

898 return 

899 self._submit_add(paths, force=False) 

900 

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

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

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

904 

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

906 

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

908 if not confirmed: 

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

910 return 

911 self._submit_add(paths, force=True) 

912 

913 self.app.push_screen( 

914 ConfirmDialog( 

915 msg.CMD_ADD_DUPLICATE_TITLE, 

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

917 ), 

918 _on_confirm, 

919 ) 

920 

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

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

923 from lilbee.cli.tui.task_queue import TaskType 

924 

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

926 

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

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

929 

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

931 

932 def _do_add( 

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

934 ) -> None: 

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

936 from lilbee.app.ingest import register_sources 

937 

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

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

940 unregistered = AddRollback(paths=paths, at_sync=False) 

941 try: 

942 with leave_as_cancel(unregistered, reporter, reporter.cancelled_by_user): 

943 reg_result = register_sources(paths, force=force) 

944 except asyncio.CancelledError as exc: 

945 raise TaskCancelledError(unregistered.message(msg.SYNC_CANCELLED_RESUME)) from exc 

946 self._notify_registration(reg_result) 

947 if not reg_result.reached_corpus: 

948 return 

949 # A source that took in another one stays: un-registering it orphans what it took. 

950 revocable = reg_result.revocable 

951 try: 

952 with forget_unfinished_on_cancel( 

953 paths, revocable, reporter, reporter.cancelled_by_user 

954 ) as rollback: 

955 self._sync_added(reg_result.registered, reporter) 

956 except asyncio.CancelledError as exc: 

957 raise TaskCancelledError(rollback.message(msg.SYNC_CANCELLED_RESUME)) from exc 

958 except BaseException: 

959 unregister_added_roots(revocable) 

960 raise 

961 

962 def _notify_registration(self, reg_result: RegisterResult) -> None: 

963 """Toast each source the add skipped, and a warning when nothing reached the corpus.""" 

964 for name in reg_result.name_taken: 

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

966 if reg_result.tracked: 

967 call_from_thread( 

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

969 ) 

970 if reg_result.overlapping_inside: 

971 names = ", ".join(reg_result.overlapping_inside) 

972 call_from_thread(self, self.notify, msg.CMD_ADD_OVERLAPPING.format(names=names)) 

973 if reg_result.containing: 

974 names = ", ".join(reg_result.containing) 

975 call_from_thread(self, self.notify, msg.CMD_ADD_CONTAINING.format(names=names)) 

976 for parent, children in reg_result.absorbed_into.items(): 

977 text = msg.CMD_ADD_ABSORBED.format(parent=parent, children=", ".join(children)) 

978 call_from_thread(self, self.notify, text) 

979 if not reg_result.reached_corpus: 

980 call_from_thread(self, self.notify, msg.CMD_ADD_NOTHING, severity="warning") 

981 

982 def _notify_unreadable_folders(self, result: SyncResult) -> None: 

983 """Warn once per folder the sync could not open, worded as the CLI summary is.""" 

984 for folder in result.unreadable_folders: 

985 call_from_thread( 

986 self, 

987 self.notify, 

988 msg.SYNC_UNREADABLE_FOLDER.format(folder=folder), 

989 severity="warning", 

990 ) 

991 

992 def _sync_added(self, registered: list[str], reporter: ProgressReporter) -> None: 

993 """Sync after /add and report the result; raises when the add's own roots failed.""" 

994 from lilbee.data.ingest import sync 

995 

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

997 sync_result = asyncio_loop.run( 

998 sync( 

999 quiet=True, 

1000 on_progress=build_add_progress_callback(reporter), 

1001 cancel=reporter, 

1002 started_by="the lilbee TUI", 

1003 ) 

1004 ) 

1005 self._notify_unreadable_folders(sync_result) 

1006 if sync_result.failed: 

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

1008 if sync_result.skipped: 

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

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

1011 skipped_msg = msg.sync_skipped_message(sync_result, tui_log_path()) 

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

1013 raise RuntimeError(skipped_msg) 

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

1015 if sync_result.relocated: 

1016 call_from_thread( 

1017 self, 

1018 self.notify, 

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

1020 ) 

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

1022 

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

1024 self._stop_turn() 

1025 for worker in self.workers: 

1026 if worker.group != _TURN_WORKER_GROUP: 

1027 worker.cancel() 

1028 self.notify(msg.CMD_CANCEL) 

1029 

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

1031 self._reset_conversation() 

1032 self.notify(msg.CMD_CLEAR) 

1033 

1034 def _reset_conversation(self) -> None: 

1035 """Stop the live turn, empty the log and history, and drop the active session. 

1036 

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

1038 next user turn open a fresh one. 

1039 """ 

1040 self._stop_turn() 

1041 self._chat_log.remove_children() 

1042 self._active_question = None 

1043 with self._history_lock: 

1044 self._history.clear() 

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

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

1047 self._summary = "" 

1048 self._conversation_generation += 1 

1049 self._session_id = None 

1050 

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

1052 if not crawler_available(): 

1053 self.notify(CRAWLER_MISSING_MESSAGE, severity="error") 

1054 return 

1055 if not args: 

1056 self._open_crawl_dialog() 

1057 return 

1058 parts = args.split() 

1059 url = parts[0] 

1060 if not is_url(url): 

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

1062 try: 

1063 require_valid_crawl_url(url) 

1064 except ValueError as exc: 

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

1066 return 

1067 try: 

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

1069 except ValueError as exc: 

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

1071 return 

1072 if (too_large := crawl_limit_error(depth, max_pages)) is not None: 

1073 self.notify(too_large, severity="error") 

1074 return 

1075 self._start_crawl( 

1076 url, 

1077 depth, 

1078 max_pages, 

1079 include_subdomains=include_subdomains, 

1080 render_mode=render_mode, 

1081 ) 

1082 

1083 def _open_crawl_dialog(self) -> None: 

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

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

1086 

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

1088 if result is not None: 

1089 self._start_crawl( 

1090 result.url, 

1091 result.depth, 

1092 result.max_pages, 

1093 render_mode=result.render_mode, 

1094 login=result.login, 

1095 ) 

1096 

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

1098 

1099 def _start_crawl( 

1100 self, 

1101 url: str, 

1102 depth: int | None, 

1103 max_pages: int | None, 

1104 *, 

1105 include_subdomains: bool = False, 

1106 render_mode: CrawlRenderMode | None = None, 

1107 login: CrawlLogin = NO_LOGIN, 

1108 ) -> None: 

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

1110 

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

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

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

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

1115 sticks for the next crawl. ``login`` is the one typed for this crawl; it 

1116 goes over the stored login to the seed host and is kept nowhere. 

1117 """ 

1118 from lilbee.cli.tui.task_queue import TaskType 

1119 

1120 try: 

1121 login = crawl_login([url], login) 

1122 except ValueError as exc: 

1123 self.notify(msg.CRAWL_DIALOG_INVALID_LOGIN.format(error=exc), severity="error") 

1124 return 

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

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

1127 self._persist_crawl_render_mode(render_mode) 

1128 

1129 shown = without_userinfo(url) 

1130 

1131 def _kick_off_crawl() -> None: 

1132 self._task_bar.start_task( 

1133 msg.TASK_NAME_CRAWL.format(url=shown), 

1134 TaskType.CRAWL, 

1135 lambda reporter: self._do_crawl( 

1136 url, 

1137 depth, 

1138 max_pages, 

1139 reporter, 

1140 include_subdomains=include_subdomains, 

1141 render_mode=mode, 

1142 login=login, 

1143 ), 

1144 on_success=lambda: call_from_thread(self, self.run_sync), 

1145 ) 

1146 

1147 self.notify(msg.CMD_CRAWL_STARTED.format(url=shown)) 

1148 if mode is CrawlRenderMode.BROWSER: 

1149 self._task_bar.ensure_chromium(_kick_off_crawl) 

1150 else: 

1151 _kick_off_crawl() 

1152 

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

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

1155 from lilbee.app.settings import apply_settings_update 

1156 

1157 try: 

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

1159 except (ValueError, OSError) as exc: 

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

1161 

1162 @staticmethod 

1163 def _parse_crawl_flags( 

1164 tokens: list[str], 

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

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

1167 

1168 Numeric flags return None when absent so the caller inherits 

1169 crawl_and_save's unbounded-by-default semantics. A numeric flag whose 

1170 value is not a whole number raises ValueError naming the flag. The boolean 

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

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

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

1174 """ 

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

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

1177 include_subdomains = False 

1178 render_mode: CrawlRenderMode | None = None 

1179 i = 0 

1180 while i < len(tokens): 

1181 if tokens[i] == _CRAWL_FLAG_INCLUDE_SUBDOMAINS: 

1182 include_subdomains = True 

1183 i += 1 

1184 continue 

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

1186 with contextlib.suppress(ValueError): 

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

1188 i += 2 

1189 continue 

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

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

1192 try: 

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

1194 except ValueError: 

1195 message = msg.CMD_CRAWL_FLAG_NOT_A_NUMBER.format(flag=tokens[i]) 

1196 raise ValueError(message) from None 

1197 i += 2 

1198 else: 

1199 i += 1 

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

1201 

1202 def _do_crawl( 

1203 self, 

1204 url: str, 

1205 depth: int | None, 

1206 max_pages: int | None, 

1207 reporter: ProgressReporter, 

1208 *, 

1209 include_subdomains: bool = False, 

1210 render_mode: CrawlRenderMode | None = None, 

1211 login: CrawlLogin = NO_LOGIN, 

1212 ) -> None: 

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

1214 from lilbee.crawler import crawl_and_save 

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

1216 

1217 shown = without_userinfo(url) 

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

1219 failures: list[str] = [] 

1220 setup_labels = {BROWSER_SETUP_COMPONENT: msg.SETUP_BROWSER_WARM_UP} 

1221 

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

1223 if event_type == EventType.SETUP_START and isinstance(data, SetupStartEvent): 

1224 reporter.update(0, setup_labels.get(data.component, msg.SETUP_CHROMIUM_NAME)) 

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

1226 if data.total_bytes: 

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

1228 detail = msg.SETUP_CHROMIUM_DETAIL.format( 

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

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

1231 ) 

1232 else: 

1233 pct = 0 

1234 detail = msg.SETUP_CHROMIUM_DETAIL_UNKNOWN.format( 

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

1236 ) 

1237 reporter.update(pct, detail) 

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

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

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

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

1242 # soon as the total is known. 

1243 if data.total > 0: 

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

1245 reporter.update( 

1246 pct, 

1247 msg.CMD_CRAWL_PAGE.format( 

1248 current=data.current, total=data.total, url=data.url 

1249 ), 

1250 indeterminate=False, 

1251 ) 

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

1253 reporter.update( 

1254 0, 

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

1256 indeterminate=True, 

1257 ) 

1258 elif event_type == EventType.CRAWL_PAGE_FAILED and isinstance( 

1259 data, CrawlPageFailedEvent 

1260 ): 

1261 failures.append(data.reason) 

1262 

1263 paths = asyncio_loop.run( 

1264 crawl_and_save( 

1265 url, 

1266 depth=depth, 

1267 max_pages=max_pages, 

1268 on_progress=on_progress, 

1269 cancel=reporter, 

1270 include_subdomains=include_subdomains, 

1271 render_mode=render_mode, 

1272 login=login, 

1273 ) 

1274 ) 

1275 reporter.check_cancelled() 

1276 call_from_thread( 

1277 self, self.notify, msg.CMD_CRAWL_SUCCESS.format(count=len(paths), url=shown) 

1278 ) 

1279 if failures: 

1280 call_from_thread( 

1281 self, 

1282 self.notify, 

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

1284 severity="warning", 

1285 ) 

1286 

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

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

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

1290 self.app.switch_view(msg.CATALOG_VIEW) 

1291 

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

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

1294 del args 

1295 self.run_sync(prune_ignored=True) 

1296 

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

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

1299 self._cmd_delete_worker(args.strip()) 

1300 

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

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

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

1304 try: 

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

1306 except Exception: 

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

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

1309 return 

1310 

1311 known = set(removable_names([s.get("filename", s.get("source", "?")) for s in sources])) 

1312 if not known: 

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

1314 return 

1315 

1316 if not name: 

1317 names = ", ".join(strip_control_characters(n) for n in sorted(known)) 

1318 usage = msg.CMD_DELETE_USAGE.format(names=names) 

1319 call_from_thread(self, self.notify, usage) 

1320 return 

1321 

1322 if name not in known: 

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

1324 suggestion = _closest_source(name, known) 

1325 if suggestion is not None: 

1326 hint = msg.CMD_DELETE_SUGGESTION.format(name=strip_control_characters(suggestion)) 

1327 message = f"{message}. {hint}" 

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

1329 return 

1330 

1331 try: 

1332 remove_documents_durably([name]) 

1333 except LIBRARY_REFUSALS as exc: 

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

1335 return 

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

1337 

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

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

1340 path = args.strip() 

1341 if not path: 

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

1343 return 

1344 from lilbee.cli.tui.task_queue import TaskType 

1345 

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

1347 self._do_export(path, reporter) 

1348 

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

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

1351 

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

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

1354 from lilbee.app.dataset import DatasetError, export_to_path 

1355 

1356 output = Path(raw_path).expanduser() 

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

1358 try: 

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

1360 # OSError: output path unwritable or library unreadable 

1361 except (DatasetError, OSError) as exc: 

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

1363 raise RuntimeError(str(exc)) from exc 

1364 call_from_thread( 

1365 self, 

1366 self.notify, 

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

1368 ) 

1369 

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

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

1372 path = args.strip() 

1373 if not path: 

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

1375 return 

1376 if self._sync_work_live(): 

1377 self._notify_sync_refused() 

1378 return 

1379 from lilbee.cli.tui.task_queue import TaskType 

1380 

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

1382 try: 

1383 self._do_import(path, reporter) 

1384 finally: 

1385 self._task_bar.start_detect_pending() 

1386 

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

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

1389 

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

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

1392 from lilbee.app.dataset import DatasetError, import_from_path 

1393 

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

1395 try: 

1396 summary = asyncio_loop.run( 

1397 import_from_path( 

1398 Path(raw_path).expanduser(), 

1399 "", 

1400 on_progress=build_import_progress_callback(reporter), 

1401 ) 

1402 ) 

1403 except DatasetError as exc: 

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

1405 raise RuntimeError(str(exc)) from exc 

1406 call_from_thread( 

1407 self, 

1408 self.notify, 

1409 msg.CMD_IMPORT_SUCCESS.format( 

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

1411 ), 

1412 ) 

1413 

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

1415 self.action_show_command_catalog() 

1416 

1417 def action_show_command_catalog(self) -> None: 

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

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

1420 

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

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

1423 self._enter_insert_mode() 

1424 inp = self._chat_input 

1425 inp.value = f"{name} " 

1426 inp.action_end() 

1427 

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

1429 if name is None: 

1430 return 

1431 self.insert_slash_command(name) 

1432 

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

1434 token = args.strip() 

1435 if not token: 

1436 import webbrowser 

1437 

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

1439 self.notify(msg.CHAT_LOGIN_PROMPT) 

1440 return 

1441 self._run_hf_login(token) 

1442 

1443 @work(thread=True) 

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

1445 try: 

1446 from huggingface_hub import login 

1447 

1448 login(token=token, add_to_git_credential=False) 

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

1450 except Exception as exc: 

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

1452 call_from_thread( 

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

1454 ) 

1455 

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

1457 if args: 

1458 from lilbee.catalog.formatting import display_label_for_ref 

1459 

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

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

1462 self.notify( 

1463 msg.CMD_MODEL_SET.format(name=display_label_for_ref(cfg.chat_model)), 

1464 ) 

1465 self.apply_model_change() 

1466 self.refresh_model_bar() 

1467 else: 

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

1469 

1470 self.app.push_screen(CatalogScreen()) 

1471 

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

1473 self.app.exit() 

1474 

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

1476 name = args.strip() 

1477 if not name: 

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

1479 return 

1480 self._run_remove_model(name) 

1481 

1482 @work(thread=True) 

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

1484 mgr = get_services().model_manager 

1485 try: 

1486 # The remove call is the only check: a pre-check would answer 

1487 # "installed?" a second way and disagree with `lilbee model rm`. 

1488 if mgr.remove(name): 

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

1490 else: 

1491 call_from_thread( 

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

1493 ) 

1494 except Exception: 

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

1496 call_from_thread( 

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

1498 ) 

1499 

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

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

1502 

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

1504 if not confirmed: 

1505 return 

1506 self.run_sync(force_rebuild=True) 

1507 

1508 self.app.push_screen( 

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

1510 _on_confirm, 

1511 ) 

1512 

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

1514 self.request_reset() 

1515 

1516 def request_reset(self) -> None: 

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

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

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

1520 

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

1522 if not confirmed: 

1523 return 

1524 from lilbee.app.reset import perform_reset 

1525 

1526 try: 

1527 result = perform_reset() 

1528 except ResetRefusedError as exc: 

1529 self.notify(str(exc), severity="warning") 

1530 return 

1531 except Exception as exc: 

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

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

1534 return 

1535 

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

1537 reset_store() 

1538 

1539 if result.skipped: 

1540 self.notify( 

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

1542 severity="warning", 

1543 ) 

1544 else: 

1545 self.notify(msg.CMD_RESET_SUCCESS) 

1546 

1547 self.app.push_screen( 

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

1549 _on_confirm, 

1550 ) 

1551 

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

1553 if not args: 

1554 return 

1555 parts = args.split(None, 1) 

1556 key = parts[0] 

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

1558 

1559 if key not in SETTINGS_MAP: 

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

1561 return 

1562 

1563 defn = SETTINGS_MAP[key] 

1564 if not defn.writable and key not in CLEARABLE_MODEL_FIELDS: 

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

1566 return 

1567 clears = defn.nullable and value.lower() in ("none", "null", "") 

1568 try: 

1569 parsed: object 

1570 if key in CLEARABLE_MODEL_FIELDS: 

1571 parsed = "" if clears else value 

1572 elif defn.type is bool: 

1573 # The text goes to the shared write, which refuses what it cannot read. 

1574 parsed = value 

1575 elif clears: 

1576 parsed = None 

1577 else: 

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

1579 self.notify( 

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

1581 severity="error", 

1582 ) 

1583 return 

1584 try: 

1585 parsed = list_setting_value(value) if defn.type is list else defn.type(value) 

1586 except (ValueError, TypeError): 

1587 self.notify( 

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

1589 severity="error", 

1590 ) 

1591 return 

1592 # Route through set_setting so settings_changed_signal subscribers 

1593 # (model bar, scope chip, status bar) refresh. 

1594 self._save_and_echo(key, defn, parsed) 

1595 except (ValueError, TypeError) as exc: 

1596 reason = _refusal_text(exc) 

1597 self.notify(msg.CMD_SET_INVALID.format(key=key, error=reason), severity="error") 

1598 

1599 def _save_and_echo(self, key: str, defn: SettingDef, parsed: object) -> None: 

1600 """Save *parsed*, then echo it. A failed write toasts in set_setting and echoes nothing.""" 

1601 if not self.app.set_setting(key, parsed): 

1602 return 

1603 shown = _set_echo(key, parsed, secret=defn.secret, show_stored=defn.type in (bool, list)) 

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

1605 

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

1607 self.app.switch_view("Settings") 

1608 

1609 def _cmd_profile(self, args: str) -> None: 

1610 name = args.strip() 

1611 if not name: 

1612 self.app.open_profile_tab() 

1613 return 

1614 start_profile_switch(self.app, self, name, on_close=lambda: None) 

1615 

1616 def _cmd_analyze(self, args: str) -> None: 

1617 arg = args.strip() 

1618 words: dict[str, Callable[[], None]] = { 

1619 ANALYZE_OFF_ARG: self._hide_tip, 

1620 ANALYZE_REPORT_ARG: lambda: open_report(self.app), 

1621 } 

1622 word = words.get(arg.lower()) 

1623 if word is not None: 

1624 word() 

1625 return 

1626 directory = _whole_path(arg) if arg else None 

1627 if directory is not None and not directory.is_dir(): 

1628 self.notify(msg.ANALYZE_NOT_A_FOLDER.format(path=directory), severity="error") 

1629 return 

1630 start_analysis(self.app, directory) 

1631 

1632 def _hide_tip(self) -> None: 

1633 run_profile_op(self, lambda: hide_tip(cfg.data_root), self._tip_hidden) 

1634 

1635 def _tip_hidden(self, _result: None) -> None: 

1636 self._arg_hint.refresh_tip() 

1637 self.notify(msg.ANALYZE_TIP_HIDDEN) 

1638 

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

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

1641 self._cmd_remember_worker(args) 

1642 

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

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

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

1646 outcome = remember_from_input(raw) 

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

1648 

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

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

1651 

1652 self.app.push_screen(MemoriesScreen()) 

1653 

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

1655 self.app.switch_view("Status") 

1656 

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

1658 if not args: 

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

1660 self.insert_slash_command("/theme") 

1661 return 

1662 if args not in DARK_THEMES: 

1663 self.notify( 

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

1665 severity="warning", 

1666 ) 

1667 return 

1668 self.app.set_theme(args) 

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

1670 

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

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

1673 

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

1675 if not cfg.wiki: 

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

1677 return 

1678 self.app.switch_view("Wiki") 

1679 

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

1681 self.app.call_later(self.app.action_toggle_sessions) 

1682 

1683 def _read_active_session(self, no_session: str, gone: str) -> Session | None: 

1684 """The active session as the store holds it, or None after telling the user why not.""" 

1685 if not cfg.sessions_enabled: 

1686 self.app.notify_sessions_disabled() 

1687 return None 

1688 if self._session_id is None: 

1689 self.notify(no_session, severity="warning") 

1690 return None 

1691 try: 

1692 return get_services().session_store.get(self._session_id) 

1693 except SessionNotFoundError: 

1694 self.notify(gone, severity="warning") 

1695 return None 

1696 

1697 def _cmd_fork(self, _args: str) -> None: 

1698 """Open the fork picker over the answers of the current session as the store holds it.""" 

1699 source = self._read_active_session(msg.FORK_NO_SESSION, msg.FORK_SESSION_GONE) 

1700 if source is None: 

1701 return 

1702 points = fork_points(source.messages) 

1703 if not points: 

1704 self.notify(msg.FORK_NO_ANSWER, severity="warning") 

1705 return 

1706 self.app.push_screen(ForkPicker(points), partial(self._on_fork_picked, source)) 

1707 

1708 def _cmd_export_chat(self, args: str) -> None: 

1709 """Write the current session as markdown to *args*, or to the working directory.""" 

1710 session = self._read_active_session( 

1711 msg.EXPORT_CHAT_NO_SESSION, msg.EXPORT_CHAT_SESSION_GONE 

1712 ) 

1713 if session is None: 

1714 return 

1715 try: 

1716 path = write_session_markdown(session, args.strip() or ".") 

1717 except OSError as exc: 

1718 self.notify(msg.EXPORT_CHAT_FAILED.format(error=exc), severity="error") 

1719 return 

1720 self.notify(msg.EXPORT_CHAT_DONE.format(path=path)) 

1721 

1722 def _on_fork_picked(self, source: Session, message_count: int | None) -> None: 

1723 """Fork *source* after the picked answer and switch to the fork with an empty input.""" 

1724 if message_count is None: 

1725 return 

1726 try: 

1727 fork_id = get_services().session_store.fork( 

1728 source.meta.id, message_count=message_count, origin=SessionOrigin.TUI 

1729 ) 

1730 except SessionNotFoundError: 

1731 self.notify(msg.FORK_SESSION_GONE, severity="warning") 

1732 return 

1733 except SessionOwnershipError as exc: 

1734 self.notify(msg.FORK_FAILED.format(error=exc), severity="warning") 

1735 return 

1736 fork = self._load_session(fork_id) 

1737 self.notify(msg.FORK_DONE.format(title=strip_control_characters(fork.meta.title))) 

1738 self._set_input("") 

1739 self._enter_insert_mode() 

1740 

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

1742 """Send a user message and stream the response, unless a turn is live.""" 

1743 from textual.css.query import NoMatches 

1744 

1745 if self.streaming: 

1746 self._notify_busy() 

1747 return 

1748 # A terminal without focus events never tells the app that another shell changed 

1749 # config.toml, so each question checks the file before it reads cfg. 

1750 self.app.adopt_external_settings() 

1751 log = self._chat_log 

1752 with contextlib.suppress(NoMatches): 

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

1754 question = UserMessage(text) 

1755 log.mount(question) 

1756 self._active_question = question 

1757 

1758 # The assistant bubble owns its own ThinkingHeader animator until 

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

1760 assistant_msg = AssistantMessage() 

1761 log.mount(assistant_msg) 

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

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

1764 log.anchor() 

1765 

1766 with self._history_lock: 

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

1768 self._persist_user_turn(text) 

1769 turn = _Turn(text, assistant_msg, self._conversation_generation, self._session_id) 

1770 self._turn = turn 

1771 self.streaming = True 

1772 self._stream_response(turn, self._current_chunk_type()) 

1773 

1774 def _current_scope_value(self) -> str: 

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

1776 from textual.css.query import NoMatches 

1777 

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

1779 

1780 try: 

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

1782 except NoMatches: 

1783 return SearchScope.BOTH.value 

1784 return chip.scope 

1785 

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

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

1788 

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

1790 otherwise. 

1791 """ 

1792 return scope_to_chunk_type(self._current_scope_value()) 

1793 

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

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

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

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

1798 self._session_id = session_id 

1799 return session_id 

1800 

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

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

1803 if not cfg.sessions_enabled: 

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

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

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

1807 return 

1808 store = get_services().session_store 

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

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

1811 try: 

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

1813 except SessionNotFoundError: 

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

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

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

1817 

1818 def _save_assistant_turn( 

1819 self, session_id: str | None, content: str, sources: list[SourceRef] 

1820 ) -> None: 

1821 """Persist the assistant turn, telling the user when the save fails. Worker thread.""" 

1822 try: 

1823 self._persist_assistant_turn(session_id, content, sources) 

1824 except (OSError, SessionOwnershipError) as exc: 

1825 log.warning("Could not save the assistant turn", exc_info=True) 

1826 call_from_thread( 

1827 self, self.notify, msg.SESSIONS_SAVE_FAILED.format(error=exc), severity="error" 

1828 ) 

1829 

1830 def _persist_assistant_turn( 

1831 self, session_id: str | None, content: str, sources: list[SourceRef] 

1832 ) -> None: 

1833 """Append the assistant turn and its Sources list to *session_id*, the session 

1834 its turn started in.""" 

1835 if session_id is None or not cfg.sessions_enabled: 

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

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

1838 # relying on _persist_user_turn having left the id unset. 

1839 return 

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

1841 with contextlib.suppress(SessionNotFoundError): 

1842 get_services().session_store.add_message( 

1843 session_id, 

1844 message_with_refs( 

1845 MessageRole.ASSISTANT, 

1846 content, 

1847 tuple(StoredSourceRef(r.source, r.citation_number, r.locator) for r in sources), 

1848 ), 

1849 surface=SessionOrigin.TUI, 

1850 ) 

1851 

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

1853 """Load a saved session into the chat view, make it the active one, and focus the prompt.""" 

1854 session = self._load_session(session_id) 

1855 self.focus_prompt() 

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

1857 

1858 def _load_session(self, session_id: str) -> Session: 

1859 """Replace the conversation with a saved session's transcript and summary.""" 

1860 session = get_services().session_store.get(session_id) 

1861 self._reset_conversation() 

1862 self._session_id = session_id 

1863 for message in session.messages: 

1864 self._render_restored_message(message) 

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

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

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

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

1869 # what a resumed conversation must not do. 

1870 loaded: list[ChatMessage] = [ 

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

1872 ] 

1873 with self._history_lock: 

1874 self._history = loaded 

1875 self._summary = session.summary 

1876 self._restore_session_model(session.meta.model_ref) 

1877 self._refresh_context_usage() 

1878 self._chat_log.scroll_end(animate=False) 

1879 return session 

1880 

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

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

1883 

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

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

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

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

1888 """ 

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

1890 return 

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

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

1893 else: 

1894 self.notify( 

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

1896 severity="warning", 

1897 ) 

1898 

1899 @property 

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

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

1902 return self._session_id 

1903 

1904 def start_new_conversation(self) -> None: 

1905 """Clear the conversation, open a fresh session on the next turn, and focus the prompt.""" 

1906 self._reset_conversation() 

1907 self.focus_prompt() 

1908 self.notify(msg.SESSIONS_NEW) 

1909 

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

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

1912 log = self._chat_log 

1913 if message.role == MessageRole.USER: 

1914 log.mount(UserMessage(strip_control_characters(message.content, keep_lines=True))) 

1915 return 

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

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

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

1919 # An older TUI turn's Sources list is read from its content into refs. 

1920 text, sources = message_sources(message) 

1921 log.mount( 

1922 AssistantMessage( 

1923 content=strip_control_characters(text, keep_lines=True), sources=sources 

1924 ) 

1925 ) 

1926 

1927 @work(thread=True, group=_TURN_WORKER_GROUP) 

1928 def _stream_response(self, turn: _Turn, chunk_type: ChunkType | None) -> None: 

1929 """Run *turn* on a background thread and end it, whatever happens.""" 

1930 try: 

1931 self._do_stream_response(turn, chunk_type) 

1932 finally: 

1933 post_from_thread(self, _TurnEnded(turn)) 

1934 

1935 def _do_stream_response(self, turn: _Turn, chunk_type: ChunkType | None) -> None: 

1936 """Stream the answer to *turn*'s question and save it. Worker thread.""" 

1937 response_parts: list[str] = [] 

1938 stream: Any = None 

1939 try: 

1940 if not self._await_chat_engine(turn) or self._turn_stopped(turn): 

1941 return 

1942 self._compact_history(turn.session_id, turn.generation) 

1943 if self._turn_stopped(turn): 

1944 return 

1945 with self._history_lock: 

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

1947 recent = self._history[:-1] 

1948 summary = self._summary 

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

1950 stream = get_services().searcher.ask_stream( 

1951 turn.question, history=history_snapshot, chunk_type=chunk_type 

1952 ) 

1953 self._consume_stream(stream, turn, response_parts) 

1954 except EmbeddingModelMismatchError as exc: 

1955 with contextlib.suppress(Exception): 

1956 call_from_thread(self, self._on_embedding_mismatch, exc, turn.question, turn.widget) 

1957 except Exception as exc: 

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

1959 # A stop severs the transport, which surfaces here as a stream error; 

1960 # the turn's end already says it was cancelled. 

1961 if not self._turn_stopped(turn): 

1962 with contextlib.suppress(Exception): 

1963 call_from_thread(self, turn.widget.append_content, _stream_error_text(exc)) 

1964 finally: 

1965 close_stream(stream) 

1966 turn.answer = "".join(response_parts) 

1967 self._save_turn(turn) 

1968 

1969 @staticmethod 

1970 def _turn_stopped(turn: _Turn) -> bool: 

1971 """Whether *turn* was asked to stop, or Textual is tearing its worker down.""" 

1972 if turn.stop.is_set(): 

1973 return True 

1974 try: 

1975 return _get_worker().is_cancelled 

1976 except NoActiveWorker: 

1977 return False 

1978 

1979 def _await_chat_engine(self, turn: _Turn) -> bool: 

1980 """Hold the stream until the engine can serve, painting the load into *turn*'s bubble. 

1981 

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

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

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

1985 False once the turn was stopped or the load failed, with any failure 

1986 already rendered into the bubble. 

1987 """ 

1988 from lilbee.app.placement import ( 

1989 chat_engine_ready, 

1990 chat_warm_error, 

1991 request_engine_warm, 

1992 wait_chat_ready, 

1993 ) 

1994 

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

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

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

1998 # lazily rebuilding the way ask_stream always has. 

1999 get_services() 

2000 if chat_engine_ready(): 

2001 return True 

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

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

2004 request_engine_warm() 

2005 self._show_warm_tip_once() 

2006 widget = turn.widget 

2007 

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

2009 with contextlib.suppress(Exception): 

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

2011 

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

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

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

2015 with contextlib.suppress(Exception): 

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

2017 if wait_chat_ready(on_progress=_paint, should_abort=lambda: self._turn_stopped(turn)): 

2018 with contextlib.suppress(Exception): 

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

2020 return True 

2021 if self._turn_stopped(turn): 

2022 return False 

2023 error = chat_warm_error() 

2024 text = ( 

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

2026 if error is not None 

2027 else msg.ENGINE_NOT_READY 

2028 ) 

2029 with contextlib.suppress(Exception): 

2030 call_from_thread(self, widget.append_content, text) 

2031 return False 

2032 

2033 def _show_warm_tip_once(self) -> None: 

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

2035 if cfg.keep_engine_warm or self._warm_tip_shown: 

2036 return 

2037 self._warm_tip_shown = True 

2038 with contextlib.suppress(Exception): 

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

2040 

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

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

2043 

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

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

2046 """ 

2047 from lilbee.app.memory import auto_extract_enabled 

2048 

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

2050 return 

2051 self._extract_memories_worker(question, answer) 

2052 

2053 def _indexing_active(self) -> bool: 

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

2055 

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

2057 re-indexes the page it publishes. 

2058 """ 

2059 from lilbee.cli.tui.task_queue import TaskType 

2060 

2061 busy = { 

2062 TaskType.SYNC.value, 

2063 TaskType.ADD.value, 

2064 TaskType.IMPORT.value, 

2065 TaskType.WIKI.value, 

2066 } 

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

2068 

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

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

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

2072 from lilbee.app.memory import auto_extract 

2073 

2074 stored = auto_extract(question, answer) 

2075 if stored: 

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

2077 

2078 def _on_embedding_mismatch( 

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

2080 ) -> None: 

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

2082 if not exc.dims_match: 

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

2084 return 

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

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

2087 

2088 self.app.push_screen( 

2089 ConfirmDialog( 

2090 msg.EMBED_ADOPT_CONFIRM_TITLE, 

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

2092 ), 

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

2094 ) 

2095 

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

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

2098 if not confirmed: 

2099 self.notify(msg.EMBED_ADOPT_CANCELLED) 

2100 return 

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

2102 self._adopt_and_retry(ref, question) 

2103 

2104 @work(thread=True) 

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

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

2107 self._do_adopt_and_retry(ref, question) 

2108 

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

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

2111 from lilbee.app.models import adopt_embedder 

2112 

2113 try: 

2114 result = adopt_embedder(ref) 

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

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

2117 call_from_thread( 

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

2119 ) 

2120 return 

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

2122 if result.reindex_required: # the re-asked question would meet the same refusal 

2123 call_from_thread(self, self._offer_adopted_rebuild) 

2124 return 

2125 call_from_thread(self, self._send_message, question) 

2126 

2127 def _offer_adopted_rebuild(self) -> None: 

2128 """Offer the rebuild an adopted embedder still needs.""" 

2129 self.app.offer_rebuild(msg.SETTINGS_REINDEX_MESSAGE) 

2130 

2131 def _consume_stream(self, stream: Any, turn: _Turn, response_parts: list[str]) -> None: 

2132 """Pull tokens off *stream* into *turn*'s bubble, batching UI updates to ~50 ms windows.""" 

2133 widget = turn.widget 

2134 reason_buf: list[str] = [] 

2135 content_buf: list[str] = [] 

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

2137 

2138 def flush() -> None: 

2139 if reason_buf: 

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

2141 reason_buf.clear() 

2142 if content_buf: 

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

2144 content_buf.clear() 

2145 

2146 for token in stream: 

2147 if self._turn_stopped(turn): 

2148 break 

2149 try: 

2150 if isinstance(token, RetrievalNotice): 

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

2152 call_from_thread(self, widget.set_thinking_status, status) 

2153 continue 

2154 if isinstance(token, AnswerSources): 

2155 # Shown under the answer, saved beside it: never part of its text. 

2156 turn.source_refs = token.refs() 

2157 call_from_thread(self, widget.show_sources, turn.source_refs) 

2158 continue 

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

2160 self._maybe_flush(flush, timings) 

2161 except Exception: 

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

2163 with contextlib.suppress(Exception): 

2164 flush() 

2165 

2166 @staticmethod 

2167 def _buffer_token( 

2168 token: Any, 

2169 reason_buf: list[str], 

2170 content_buf: list[str], 

2171 response_parts: list[str], 

2172 ) -> None: 

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

2174 if token.is_reasoning: 

2175 reason_buf.append(token.content) 

2176 elif token.content: 

2177 response_parts.append(token.content) 

2178 content_buf.append(token.content) 

2179 

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

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

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

2183 """ 

2184 now = time.monotonic() 

2185 if now - timings.last_flush >= _STREAM_FLUSH_INTERVAL: 

2186 flush() 

2187 timings.last_flush = now 

2188 

2189 def _save_turn(self, turn: _Turn) -> None: 

2190 """Add *turn*'s answer to the history and its session. Worker thread. 

2191 

2192 The answer joins the history only while the turn's conversation is still 

2193 on screen; it is saved to the turn's session either way. 

2194 """ 

2195 if not turn.answer: 

2196 return 

2197 with self._history_lock: 

2198 # A resume or /clear mid-answer replaced the conversation; the 

2199 # answer belongs to the one that is gone. 

2200 if turn.generation == self._conversation_generation: 

2201 self._history.append({"role": "assistant", "content": turn.answer}) 

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

2203 # trimming now would drop turns without folding them in. 

2204 self._save_assistant_turn(turn.session_id, turn.answer, turn.source_refs) 

2205 

2206 @on(_TurnEnded) 

2207 def _on_turn_ended(self, event: _TurnEnded) -> None: 

2208 """Drop the busy flag first, then finish the turn's bubble if it is on screen.""" 

2209 turn = event.turn 

2210 self._turn = None 

2211 self.stopping = False 

2212 self.streaming = False 

2213 self._maybe_extract_memories(turn.question, turn.answer) 

2214 if turn.generation == self._conversation_generation: 

2215 self._show_turn_end(turn) 

2216 

2217 def _show_turn_end(self, turn: _Turn) -> None: 

2218 """Finish *turn*'s bubble, its cancel note, the context chip and the search toast.""" 

2219 if turn.stop.is_set(): 

2220 turn.widget.append_content(msg.STREAM_CANCELLED) 

2221 turn.widget.finish() 

2222 if not turn.answer: 

2223 return 

2224 self._refresh_context_usage() 

2225 if ( 

2226 cfg.chat_mode == ChatMode.SEARCH.value 

2227 and self._embedding_ready() 

2228 and not turn.source_refs 

2229 ): 

2230 self._notify_no_results() 

2231 

2232 def _notify_no_results(self) -> None: 

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

2234 

2235 @staticmethod 

2236 def _history_budget() -> int: 

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

2238 return history_budget(cfg.chat_n_ctx_target) 

2239 

2240 def _compact_history(self, session_id: str | None, generation: int) -> None: 

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

2242 

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

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

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

2246 

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

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

2249 user sends another turn meanwhile. The result applies only while 

2250 *generation* is still the conversation on screen, and the summary is 

2251 saved to *session_id*, the session the turn started in. 

2252 """ 

2253 with self._history_lock: 

2254 history = list(self._history) 

2255 summary = self._summary 

2256 budget = self._history_budget() 

2257 if not cfg.chat_compaction: 

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

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

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

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

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

2263 if not dropped: 

2264 return 

2265 with self._history_lock: 

2266 if generation != self._conversation_generation: 

2267 return 

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

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

2270 return 

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

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

2273 return 

2274 dropped = foldable(history) 

2275 if not dropped: 

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

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

2278 # it instead. 

2279 return 

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

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

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

2283 call_from_thread(self, self._set_compacting, True) 

2284 try: 

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

2286 finally: 

2287 call_from_thread(self, self._set_compacting, False) 

2288 with self._history_lock: 

2289 if generation != self._conversation_generation: 

2290 # A resume or /clear replaced the conversation during the model 

2291 # call; the fold belongs to the one that is gone. 

2292 return 

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

2294 self._summary = result.summary 

2295 if session_id and result.summary and cfg.sessions_enabled: 

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

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

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

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

2300 with contextlib.suppress(SessionNotFoundError): 

2301 get_services().session_store.set_summary(session_id, result.summary) 

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

2303 

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

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

2306 with contextlib.suppress(NoMatches): 

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

2308 

2309 def _refresh_context_usage(self) -> None: 

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

2311 

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

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

2314 """ 

2315 with self._history_lock: 

2316 history = list(self._history) 

2317 summary = self._summary 

2318 budget = self._history_budget() 

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

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

2321 with contextlib.suppress(NoMatches): 

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

2323 

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

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

2326 

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

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

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

2330 user has navigated off the chat screen. 

2331 """ 

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

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

2334 anchor = self._active_question 

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

2336 anchor = None 

2337 with contextlib.suppress(NoMatches): 

2338 for title in titles: 

2339 rule = Static( 

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

2341 classes="compaction-marker", 

2342 ) 

2343 if anchor is None: 

2344 self._chat_log.mount(rule) 

2345 else: 

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

2347 

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

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

2350 

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

2352 consistently, different words because nothing was summarized. 

2353 """ 

2354 self._refresh_context_usage() 

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

2356 with contextlib.suppress(NoMatches): 

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

2358 

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

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

2361 

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

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

2364 

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

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

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

2368 """ 

2369 self._refresh_context_usage() 

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

2371 if stranded: 

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

2373 self._mark_context_boundary(*titles) 

2374 with contextlib.suppress(NoMatches): 

2375 self.notify( 

2376 msg.CHAT_COMPACTED_STRANDED_TOAST if stranded else msg.CHAT_COMPACTED_TOAST, 

2377 severity="warning", 

2378 ) 

2379 

2380 def action_scroll_up(self) -> None: 

2381 self._chat_log.scroll_page_up() 

2382 

2383 def action_scroll_down(self) -> None: 

2384 self._chat_log.scroll_page_down() 

2385 

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

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

2388 

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

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

2391 """ 

2392 if action == "cancel_stream": 

2393 return self.streaming and not self.stopping and self._insert_mode 

2394 if action in _MODE_ACTIONS: 

2395 # A focused drawer keeps the mode keys for its own bindings. 

2396 if self._focus_in_drawer(): 

2397 return False 

2398 return action == "enter_normal_mode" or self._leaves_normal_mode(action) 

2399 if action == "enter_model_strip": 

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

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

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

2403 # letters without having to be named here. 

2404 focused = self.focused 

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

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

2407 

2408 def action_enter_normal_mode(self) -> None: 

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

2410 overlay = self._completion_overlay 

2411 if overlay.is_visible: 

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

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

2414 self._completion_origin 

2415 ): 

2416 self._set_input(self._completion_origin) 

2417 self._completion_origin = None 

2418 overlay.hide() 

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

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

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

2422 self._set_input("") 

2423 return 

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

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

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

2427 # makes sure can_focus is re-enabled. 

2428 self._enter_insert_mode() 

2429 return 

2430 self._insert_mode = False 

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

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

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

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

2435 self._chat_input.can_focus = False 

2436 self.set_focus(self._chat_log) 

2437 self._update_input_style() 

2438 

2439 def action_cancel_stream(self) -> None: 

2440 """Stop the live answer. Bound to Ctrl+C from INSERT mode.""" 

2441 self._stop_turn() 

2442 

2443 def _stop_turn(self) -> None: 

2444 """Ask the live turn to stop and sever its inference call. 

2445 

2446 The body sees the stop at the engine wait, before and after the fold, 

2447 between tokens and in its error branch; ``cancel_inference`` severs the 

2448 stream's transport to unblock a reader stuck in a socket read. The turn 

2449 stays busy until its worker ends it. 

2450 """ 

2451 turn = self._turn 

2452 if turn is None: 

2453 return 

2454 turn.stop.set() 

2455 get_services().cancel_inference() 

2456 self.stopping = True 

2457 

2458 def apply_model_change(self) -> None: 

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

2460 

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

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

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

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

2465 once without waiting for other workers. 

2466 

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

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

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

2470 and cleared answer alike. 

2471 """ 

2472 if self.swapping_model: 

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

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

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

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

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

2478 return 

2479 if self.streaming: 

2480 from lilbee.catalog.formatting import display_label_for_ref 

2481 

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

2483 # extra state. 

2484 self._model_switch_queued = True 

2485 self.app.notify( 

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

2487 ) 

2488 return 

2489 self.swapping_model = True 

2490 self.app.notify(msg.MODEL_SWAP_APPLYING) 

2491 self._reload_chat_model_worker() 

2492 

2493 def _apply_input_busy_state(self) -> None: 

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

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

2496 with no explanation. 

2497 

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

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

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

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

2502 longer mounted. 

2503 """ 

2504 no_model = not self.app.chat_is_ready 

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

2506 with contextlib.suppress(NoMatches): 

2507 inp = self._chat_input 

2508 inp.disabled = busy 

2509 if no_model: 

2510 inp.placeholder = msg.CHAT_INPUT_NO_MODEL 

2511 elif self.swapping_model: 

2512 from lilbee.catalog.formatting import display_label_for_ref 

2513 

2514 inp.placeholder = msg.CHAT_INPUT_SWITCHING.format( 

2515 name=display_label_for_ref(cfg.chat_model) 

2516 ) 

2517 elif self.reloading_placement: 

2518 inp.placeholder = msg.CHAT_INPUT_RELOADING 

2519 else: 

2520 inp.placeholder = msg.CHAT_INPUT_PLACEHOLDER_DEFAULT 

2521 if not busy and self._insert_mode: 

2522 self.set_focus(inp) 

2523 

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

2525 self._apply_input_busy_state() 

2526 

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

2528 self._apply_input_busy_state() 

2529 

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

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

2532 self.reloading_placement = event.active 

2533 

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

2535 def _reload_chat_model_worker(self) -> None: 

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

2537 

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

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

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

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

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

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

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

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

2546 rapid second swap coalesces onto the latest cfg. 

2547 """ 

2548 from lilbee.app.placement import ( 

2549 chat_warm_error, 

2550 request_engine_warm, 

2551 wait_chat_ready, 

2552 ) 

2553 

2554 worker = _get_worker() 

2555 try: 

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

2557 request_engine_warm() 

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

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

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

2561 return 

2562 if worker.is_cancelled: 

2563 return 

2564 error = None if ready else chat_warm_error() 

2565 if error: 

2566 call_from_thread(self, self._on_model_swap_failed, error) 

2567 else: 

2568 call_from_thread(self, self._on_model_swapped) 

2569 

2570 def _on_model_swapped(self) -> None: 

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

2572 from lilbee.catalog.formatting import display_label_for_ref 

2573 

2574 self.swapping_model = False 

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

2576 

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

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

2579 self.swapping_model = False 

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

2581 

2582 @on(Markdown.LinkClicked) 

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

2584 """Open a link clicked in an answer: ``file:`` citations open in the OS 

2585 default app for the file type; web links open in the browser.""" 

2586 event.stop() 

2587 if event.href.startswith("file://"): 

2588 open_local_file(event.href) 

2589 else: 

2590 self.app.open_url(event.href) 

2591 

2592 async def action_toggle_markdown(self) -> None: 

2593 """Toggle between Markdown and plain-text rendering for chat responses.""" 

2594 cfg.markdown_rendering = not cfg.markdown_rendering 

2595 use_md = cfg.markdown_rendering 

2596 chat_log = self._chat_log 

2597 for widget in chat_log.query(AssistantMessage): 

2598 await widget.rebuild_content_widget(use_md) 

2599 label = "Markdown" if use_md else "Plain text" 

2600 self.notify(msg.CHAT_RENDERING.format(label=label)) 

2601 

2602 def _sync_work_live(self) -> bool: 

2603 """True while a sync, add or import is queued, running or unwinding after a cancel.""" 

2604 return self._task_bar.has_live_work(self._sync_work_types()) 

2605 

2606 @staticmethod 

2607 def _sync_work_types() -> frozenset[str]: 

2608 from lilbee.cli.tui.task_queue import TaskType 

2609 

2610 return frozenset({TaskType.SYNC.value, TaskType.ADD.value, TaskType.IMPORT.value}) 

2611 

2612 def _notify_sync_refused(self) -> None: 

2613 """Say why a sync is refused: other work runs, or a cancelled task is still stopping.""" 

2614 queue = self._task_bar.queue 

2615 rows = queue.queued_tasks + queue.active_tasks 

2616 running = any(t.task_type in self._sync_work_types() for t in rows) 

2617 stopping = [] if running else self._task_bar.cancelling_tasks(self._sync_work_types()) 

2618 text = ( 

2619 msg.SYNC_CANCELLING.format(kind=stopping[0].task_type) 

2620 if stopping 

2621 else msg.SYNC_ALREADY_ACTIVE 

2622 ) 

2623 self.notify(text, severity="warning") 

2624 

2625 def run_sync( 

2626 self, 

2627 *, 

2628 force_rebuild: bool = False, 

2629 prune_ignored: bool = False, 

2630 ) -> None: 

2631 """Enqueue a document sync (or full rebuild) in the task bar. 

2632 

2633 While other work runs, a sync is refused and a rebuild waits for the work to end. 

2634 """ 

2635 if self._sync_work_live(): 

2636 if force_rebuild: 

2637 self._rebuild_owed = True 

2638 self.notify(msg.REBUILD_OWED) 

2639 else: 

2640 self._notify_sync_refused() 

2641 return 

2642 from lilbee.cli.tui.task_queue import TaskType 

2643 

2644 if force_rebuild: 

2645 self._rebuild_owed = False 

2646 # Clear the pending hint so the bar shows live sync progress 

2647 # instead of the stale "N docs to sync" line. 

2648 self._task_bar.clear_pending_sync() 

2649 

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

2651 try: 

2652 self._do_sync(reporter, force_rebuild=force_rebuild, prune_ignored=prune_ignored) 

2653 finally: 

2654 # Re-detect after every sync attempt: success drives the 

2655 # count to 0, failure or cancel leaves the still-pending 

2656 # files counted so the hint reappears. 

2657 self._task_bar.start_detect_pending() 

2658 

2659 label = msg.TASK_NAME_REBUILD if force_rebuild else msg.TASK_NAME_SYNC 

2660 self._task_bar.start_task(label, TaskType.SYNC, _target, indeterminate=True) 

2661 

2662 def _start_owed_rebuild(self) -> None: 

2663 """Start the owed rebuild once the last sync, add or import has ended in any way.""" 

2664 if not self._rebuild_owed or self._sync_work_live(): 

2665 return 

2666 self._rebuild_owed = False 

2667 self.run_sync(force_rebuild=True) 

2668 

2669 def _do_sync( 

2670 self, 

2671 reporter: ProgressReporter, 

2672 *, 

2673 force_rebuild: bool = False, 

2674 prune_ignored: bool = False, 

2675 ) -> None: 

2676 """Sync body. Runs on worker thread.""" 

2677 from lilbee.data.ingest import sync 

2678 

2679 reporter.update(0, msg.SYNC_STATUS_SYNCING, indeterminate=True) 

2680 on_progress = build_sync_progress_callback(reporter) 

2681 try: 

2682 result = asyncio_loop.run( 

2683 sync( 

2684 quiet=True, 

2685 on_progress=on_progress, 

2686 cancel=reporter, 

2687 force_rebuild=force_rebuild, 

2688 prune_ignored=prune_ignored, 

2689 started_by="the lilbee TUI", 

2690 ) 

2691 ) 

2692 except asyncio.CancelledError as exc: 

2693 raise TaskCancelledError(msg.SYNC_CANCELLED_RESUME) from exc 

2694 if prune_ignored: 

2695 call_from_thread(self, self.notify, msg.prune_ignored_message(len(result.removed))) 

2696 self._notify_unreadable_folders(result) 

2697 if result.failed: 

2698 # The footer notice lasts under two seconds; the toast stays long enough to read. 

2699 call_from_thread( 

2700 self, 

2701 self.notify, 

2702 msg.SYNC_FAILED_TOAST.format(count=len(result.failed)), 

2703 severity="error", 

2704 timeout=_SYNC_FAILED_TOAST_SECONDS, 

2705 ) 

2706 raise RuntimeError(msg.SYNC_FAILED_FILES.format(files=", ".join(result.failed))) 

2707 if result.skipped: 

2708 call_from_thread( 

2709 self, 

2710 self.notify, 

2711 msg.sync_skipped_message(result, tui_log_path()), 

2712 severity="warning", 

2713 ) 

2714 if result.held_out: 

2715 call_from_thread( 

2716 self, 

2717 self.notify, 

2718 msg.SYNC_HELD_OUT.format(count=len(result.held_out)), 

2719 severity="warning", 

2720 ) 

2721 

2722 def action_focus_commands(self) -> None: 

2723 """Focus chat input and pre-fill with '/' for command entry.""" 

2724 # Route through the helper so can_focus is re-enabled when this 

2725 # action fires from NORMAL mode; bare ``inp.focus()`` would 

2726 # silently no-op while the input is intentionally unfocusable. 

2727 self._enter_insert_mode() 

2728 inp = self._chat_input 

2729 if not inp.value.startswith("/"): 

2730 inp.value = "/" 

2731 inp.action_end() 

2732 

2733 def action_toggle_chat_mode(self) -> None: 

2734 """F3: flip between Search and Chat mode.""" 

2735 try: 

2736 toggle = self.query_one(ChatModeToggle) 

2737 except NoMatches: 

2738 return 

2739 if not toggle.toggle(): 

2740 return 

2741 label = ( 

2742 msg.CHAT_MODE_SEARCH_LABEL 

2743 if cfg.chat_mode == ChatMode.SEARCH.value 

2744 else msg.CHAT_MODE_CHAT_LABEL 

2745 ) 

2746 self.notify(msg.CHAT_MODE_SET.format(label=label)) 

2747 

2748 def action_cycle_scope(self) -> None: 

2749 """``s``: cycle the scope chip when it is currently visible.""" 

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

2751 

2752 try: 

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

2754 except NoMatches: 

2755 return 

2756 if chip.has_class("-hidden"): 

2757 return 

2758 chip.cycle_scope() 

2759 

2760 def action_complete(self) -> None: 

2761 """Tab: fill the shared prefix, then cycle matches (readline / vim style). 

2762 

2763 - Insert mode + chat input focused + dropdown closed but matches 

2764 exist: open it, fill the longest common prefix, else preview the 

2765 first match. 

2766 - Insert mode + chat input focused + dropdown open: fill any further 

2767 shared prefix, otherwise preview the next match. 

2768 - Insert mode + chat input focused + no matches: insert ``\\t`` so 

2769 users can type tab characters directly. 

2770 - Normal mode or focus elsewhere: advance through the focus 

2771 chain so Tab still walks every focusable widget. 

2772 """ 

2773 inp = self._chat_input 

2774 if not self._insert_mode or not inp.has_focus: 

2775 self._tab_into_fleet_or_next() 

2776 return 

2777 if self._completion_overlay.is_visible: 

2778 self._complete_from_overlay() 

2779 return 

2780 self._open_completions(self._complete_after_open) 

2781 

2782 def _complete_after_open(self, opened: bool) -> None: 

2783 """Tab, once the dropdown listing is in: complete from it, else type a tab.""" 

2784 if opened: 

2785 self._complete_from_overlay() 

2786 else: 

2787 self._chat_input.insert("\t") 

2788 

2789 def _complete_from_overlay(self) -> None: 

2790 """Fill the shared prefix of the open dropdown, else preview the next match.""" 

2791 if self._fill_common_prefix(): 

2792 return 

2793 self._preview_next() 

2794 

2795 def _focus_in_drawer(self) -> bool: 

2796 """True when keyboard focus is inside an open drawer, so Esc / Enter / i / a / o 

2797 reach that drawer's own controls instead of switching the chat mode. 

2798 

2799 Asked of the Drawer base rather than one drawer class: a drawer that had 

2800 to name itself here would otherwise swallow its own Enter until someone 

2801 noticed. 

2802 """ 

2803 return drawer_holding(self.focused) is not None 

2804 

2805 def _focus_in_model_bar(self) -> bool: 

2806 """True when focus is on any model-strip member. 

2807 

2808 Asked of the container rather than of each member class so a member 

2809 added later is covered without a second edit here. 

2810 """ 

2811 focused = self.focused 

2812 return bool(focused and any(isinstance(n, ModelBar) for n in focused.ancestors_with_self)) 

2813 

2814 def _tab_into_fleet_or_next(self) -> None: 

2815 """Jump Tab into the open Fleet drawer's first toggle so the placement 

2816 editor is reachable without tabbing past every widget; once focus is 

2817 inside the drawer, Tab cycles within it as usual.""" 

2818 drawer = first_direct_child(self.screen, FleetDrawer) 

2819 if drawer is None: 

2820 self.screen.focus_next() 

2821 return 

2822 focused = self.screen.focused 

2823 inside = focused is not None and drawer in focused.ancestors_with_self 

2824 toggles = drawer.query(".dev-toggle") 

2825 if not inside and toggles: 

2826 self.set_focus(toggles.first()) 

2827 return 

2828 self.screen.focus_next() 

2829 

2830 def action_complete_next(self) -> None: 

2831 """Ctrl+N: preview the next match, opening the dropdown if it is closed (vim ``<C-n>``).""" 

2832 if not self._chat_input.has_focus: 

2833 # Not a completion here; skip so an open overlay (e.g. the sessions 

2834 # drawer) can bind Ctrl+N instead of this priority binding eating it. 

2835 raise SkipAction() 

2836 if self._completion_overlay.is_visible: 

2837 self._preview_next() 

2838 else: 

2839 self._open_completions(self._preview_next_if_opened) 

2840 

2841 def action_complete_prev(self) -> None: 

2842 """Ctrl+P: preview the previous match, opening the dropdown if it is closed.""" 

2843 if not self._chat_input.has_focus: 

2844 return 

2845 if self._completion_overlay.is_visible: 

2846 self._preview_prev() 

2847 else: 

2848 self._open_completions(self._preview_prev_if_opened) 

2849 

2850 def _preview_next_if_opened(self, opened: bool) -> None: 

2851 if opened: 

2852 self._preview_next() 

2853 

2854 def _preview_prev_if_opened(self, opened: bool) -> None: 

2855 if opened: 

2856 self._preview_prev() 

2857 

2858 def _preview_next(self) -> None: 

2859 """Preview the highlighted match if none is previewed yet, else step forward.""" 

2860 overlay = self._completion_overlay 

2861 if self._chat_input.value == self._completion_origin: 

2862 display = overlay.get_current() 

2863 else: 

2864 display = overlay.cycle_next() 

2865 if display is not None: 

2866 self._preview_completion(display) 

2867 

2868 def _preview_prev(self) -> None: 

2869 """Step the highlight backward (wrapping to the last match) and preview it.""" 

2870 display = self._completion_overlay.cycle_prev() 

2871 if display is not None: 

2872 self._preview_completion(display) 

2873 

2874 def _open_completions(self, then: Callable[[bool], None]) -> None: 

2875 """Show the dropdown for the current input, then call *then* with whether it opened. 

2876 

2877 A path or model argument is listed on a worker, so *then* runs after the 

2878 listing and only if the input text did not change meanwhile. 

2879 """ 

2880 text = self._chat_input.value 

2881 if reads_a_folder(text): 

2882 self._list_path_completions(text, then) 

2883 return 

2884 if reads_models(text): 

2885 self._complete_models(text, then) 

2886 return 

2887 self._show_opened(text, get_completions(text), then) 

2888 

2889 def _show_opened(self, text: str, options: list[str], then: Callable[[bool], None]) -> None: 

2890 """Show *options* as the dropdown for *text* and report whether any rows opened.""" 

2891 if text != self._chat_input.value: 

2892 return 

2893 if options: 

2894 self._completion_origin = text 

2895 self._completion_overlay.show_completions(options) 

2896 then(bool(options)) 

2897 

2898 def _completion_value(self, display: str) -> str: 

2899 """Full input text produced by accepting ``display``, keeping the typed prefix. 

2900 

2901 Path completions are basenames, so the directory the user already 

2902 typed (``~/``, ``./``, absolute) is preserved and only the final 

2903 segment is replaced. 

2904 """ 

2905 text = ( 

2906 self._completion_origin 

2907 if self._completion_origin is not None 

2908 else (self._chat_input.value) 

2909 ) 

2910 if " " not in text: 

2911 return display 

2912 cmd, _, partial = text.partition(" ") 

2913 if cmd.lower() in PATH_ARG_COMMANDS: 

2914 head = path_completion_prefix(partial) 

2915 return f"{cmd} {head}{display}" 

2916 return f"{cmd} {display}" 

2917 

2918 def _set_input(self, value: str) -> None: 

2919 """Replace the input value without triggering the live-refresh of the dropdown.""" 

2920 inp = self._chat_input 

2921 if inp.value == value: 

2922 return 

2923 # The setter posts Changed asynchronously; flag one event to ignore so 

2924 # the previewed candidate doesn't re-filter (and collapse) the dropdown. 

2925 # (The value setter already moves the cursor to the end.) 

2926 self._suppress_refresh += 1 

2927 inp.value = value 

2928 

2929 def _preview_completion(self, display: str) -> None: 

2930 """Write the highlighted candidate into the input, leaving the dropdown open.""" 

2931 self._set_input(self._completion_value(display)) 

2932 

2933 def _fill_common_prefix(self) -> bool: 

2934 """Extend the input to the longest prefix shared by all matches; True if it grew.""" 

2935 overlay = self._completion_overlay 

2936 values = [self._completion_value(d) for d in overlay.options] 

2937 shared = longest_common_prefix(values) 

2938 if len(shared) <= len(self._chat_input.value): 

2939 return False 

2940 self._set_input(shared) 

2941 # Re-filter for the newly completed prefix (descends into a directory, 

2942 # narrows the model list, etc.). 

2943 self._refresh_completion_overlay() 

2944 return True 

2945 

2946 def action_history_prev(self) -> None: 

2947 """Up arrow: cycle the dropdown if visible, else recall previous history entry.""" 

2948 if not self._insert_mode: 

2949 raise SkipAction() 

2950 inp = self._chat_input 

2951 if not inp.has_focus: 

2952 raise SkipAction() 

2953 # When the completion dropdown is up, Up navigates the dropdown 

2954 # (vim/Emacs-style) rather than recalling history. 

2955 overlay = self._completion_overlay 

2956 if overlay.is_visible: 

2957 self._preview_prev() 

2958 return 

2959 if not self._input_history: 

2960 raise SkipAction() 

2961 if self._history_index == -1: 

2962 self._history_index = len(self._input_history) - 1 

2963 elif self._history_index > 0: 

2964 self._history_index -= 1 

2965 else: 

2966 return 

2967 inp.value = self._input_history[self._history_index] 

2968 inp.action_end() 

2969 

2970 def action_history_next(self) -> None: 

2971 """Down arrow: cycle the dropdown if visible, else recall next history entry.""" 

2972 if not self._insert_mode: 

2973 raise SkipAction() 

2974 inp = self._chat_input 

2975 if not inp.has_focus: 

2976 raise SkipAction() 

2977 # When the completion dropdown is up, Down navigates the dropdown. 

2978 overlay = self._completion_overlay 

2979 if overlay.is_visible: 

2980 self._preview_next() 

2981 return 

2982 if self._history_index == -1: 

2983 raise SkipAction() 

2984 if self._history_index < len(self._input_history) - 1: 

2985 self._history_index += 1 

2986 inp.value = self._input_history[self._history_index] 

2987 inp.action_end() 

2988 else: 

2989 self._history_index = -1 

2990 inp.value = "" 

2991 

2992 @on(ChatInput.Changed, "#chat-input") 

2993 def _on_chat_input_changed(self, event: ChatInput.Changed) -> None: 

2994 """Refresh arg-hint and auto-show or hide the completion dropdown.""" 

2995 if not reads_models(self._chat_input.value): 

2996 # Also on a programmatic edit: a submit clears the input that way, and 

2997 # a /remove must not leave the removed model in the next listing. 

2998 self._model_names = None 

2999 # A listing still running belongs to the session that ended; the next 

3000 # model argument starts its own, and the old result is dropped. 

3001 self._model_session += 1 

3002 self._listing_models = False 

3003 self._model_request = ("", None) 

3004 if self._suppress_refresh > 0: 

3005 # A programmatic edit (preview / accept / revert) is managing the 

3006 # overlay itself; consume one Changed and skip the live refresh. 

3007 self._suppress_refresh -= 1 

3008 self._refresh_arg_hint() 

3009 return 

3010 self._refresh_completion_overlay() 

3011 self._refresh_arg_hint() 

3012 

3013 def _refresh_completion_overlay(self) -> None: 

3014 """Live-filter the dropdown against the current input, in command and arg modes alike.""" 

3015 text = self._chat_input.value 

3016 if reads_a_folder(text): 

3017 self._list_path_completions(text) 

3018 return 

3019 if reads_models(text): 

3020 self._complete_models(text) 

3021 return 

3022 self._show_completions_for(text, get_completions(text)) 

3023 

3024 def _complete_models(self, text: str, then: Callable[[bool], None] | None = None) -> None: 

3025 """Complete a model argument from the models listed once for this input. 

3026 

3027 Until the listing ends, keep the latest request and start one listing; keys 

3028 typed meanwhile neither wait for it nor start another. 

3029 """ 

3030 if self._model_names is None: 

3031 self._model_request = (text, then) 

3032 if not self._listing_models: 

3033 self._listing_models = True 

3034 self._list_models(self._model_session) 

3035 return 

3036 options = model_completions(text, self._model_names) 

3037 if then is None: 

3038 self._show_completions_for(text, options) 

3039 else: 

3040 self._show_opened(text, options, then) 

3041 

3042 @work(thread=True, group="model_completions", exit_on_error=False) 

3043 def _list_models(self, session: int) -> None: 

3044 """List the installed models off the event loop: the listing probes local servers.""" 

3045 names = list_model_options() 

3046 if _get_worker().is_cancelled: 

3047 return 

3048 call_from_thread(self, self._models_listed, session, names) 

3049 

3050 def _models_listed(self, session: int, names: list[str]) -> None: 

3051 """Keep *names* and answer the latest request, unless the input left the model argument. 

3052 

3053 A result from an earlier model argument session is dropped. 

3054 """ 

3055 if session != self._model_session: 

3056 return 

3057 self._listing_models = False 

3058 text, then = self._model_request 

3059 self._model_request = ("", None) 

3060 current = self._chat_input.value 

3061 if not reads_models(current): 

3062 return 

3063 self._model_names = names 

3064 self._complete_models(current, then if text == current else None) 

3065 

3066 @work(thread=True, exclusive=True, group="path_completions", exit_on_error=False) 

3067 def _list_path_completions(self, text: str, then: Callable[[bool], None] | None = None) -> None: 

3068 """List the typed path's folder off the event loop; a newer request cancels this listing. 

3069 

3070 With *then* (a Tab press) the rows open the dropdown and *then* runs; without it 

3071 (a key press) the rows refresh the dropdown. 

3072 """ 

3073 options = get_completions(text) 

3074 if _get_worker().is_cancelled: 

3075 return 

3076 if then is None: 

3077 call_from_thread(self, self._show_completions_for, text, options) 

3078 else: 

3079 call_from_thread(self, self._show_opened, text, options, then) 

3080 

3081 def _show_completions_for(self, text: str, options: list[str]) -> None: 

3082 """Apply *options* if *text* is still the input; a stale listing is dropped.""" 

3083 if text != self._chat_input.value: 

3084 return 

3085 overlay = self._completion_overlay 

3086 if options: 

3087 self._completion_origin = text 

3088 overlay.show_completions(options) 

3089 elif overlay.is_visible: 

3090 overlay.hide() 

3091 self._completion_origin = None 

3092 

3093 def _refresh_arg_hint(self) -> None: 

3094 """Push the current input value into the ArgHintLine.""" 

3095 self._arg_hint.update_for_input(self._chat_input.value) 

3096 

3097 def refresh_model_bar(self) -> None: 

3098 """Re-scan installed models and refresh the model bar. 

3099 

3100 Show can arrive before the prompt area's descendants have mounted, and 

3101 on a slow runner it does. A bar that is not in the DOM yet scans on its 

3102 own mount, so a missing bar means the scan is already coming, not that 

3103 it was skipped -- the re-scan here is what re-entering the screen needs. 

3104 Querying regardless raised NoMatches out of the show handler, which 

3105 Textual re-raises as an app crash. 

3106 """ 

3107 for bar in self.query("#model-bar").results(ModelBar): 

3108 bar.refresh_models() 

3109 

3110 def action_vim_scroll_down(self) -> None: 

3111 """Vim j: scroll down in normal mode.""" 

3112 if self._insert_mode: 

3113 raise SkipAction() 

3114 self._chat_log.scroll_down() 

3115 

3116 def action_vim_scroll_up(self) -> None: 

3117 """Vim k: scroll up in normal mode.""" 

3118 if self._insert_mode: 

3119 raise SkipAction() 

3120 self._chat_log.scroll_up() 

3121 

3122 def action_vim_scroll_home(self) -> None: 

3123 """Vim g: scroll to top in normal mode.""" 

3124 if self._insert_mode: 

3125 raise SkipAction() 

3126 self._chat_log.scroll_home() 

3127 

3128 def action_vim_scroll_end(self) -> None: 

3129 """Vim G: scroll to bottom in normal mode.""" 

3130 if self._insert_mode: 

3131 raise SkipAction() 

3132 self._chat_log.scroll_end() 

3133 

3134 def action_half_page_down(self) -> None: 

3135 """Ctrl-D: half-page down (vim style).""" 

3136 log_widget = self._chat_log 

3137 half = max(1, log_widget.size.height // 2) 

3138 log_widget.scroll_relative(y=half) 

3139 

3140 def action_half_page_up(self) -> None: 

3141 """Ctrl-U: half-page up (vim style).""" 

3142 log_widget = self._chat_log 

3143 half = max(1, log_widget.size.height // 2) 

3144 log_widget.scroll_relative(y=-half)