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

1519 statements  

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

20from textual import events, getters, on, work 

21from textual.actions import SkipAction 

22from textual.app import ComposeResult 

23from textual.binding import Binding, BindingType 

24from textual.containers import Horizontal, Vertical, VerticalScroll 

25from textual.content import Content 

26from textual.css.query import NoMatches 

27from textual.dom import DOMNode 

28from textual.message import Message 

29from textual.reactive import reactive 

30from textual.screen import Screen 

31from textual.widget import Widget 

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

33 

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

35# since it's used in multiple methods. 

36from textual.worker import NoActiveWorker 

37from textual.worker import get_current_worker as _get_worker 

38 

39from lilbee.app.ingest import removable_names, remove_documents_durably 

40from lilbee.app.services import get_services, reset_store 

41from lilbee.app.session_export import write_session_markdown 

42from lilbee.app.settings_map import SETTINGS_MAP 

43from lilbee.app.themes import DARK_THEMES 

44from lilbee.app.version import get_version 

45from lilbee.cli.tui import messages as msg 

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

47from lilbee.cli.tui.command_registry import runs_while_streaming 

48from lilbee.cli.tui.log_routing import tui_log_path 

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

50 add_indexed_anything, 

51 build_add_progress_callback, 

52 build_import_progress_callback, 

53 build_sync_progress_callback, 

54 close_stream, 

55 open_local_file, 

56 remember_from_input, 

57 unregister_added_roots, 

58) 

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

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

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

62 PATH_ARG_COMMANDS, 

63 CompletionOverlay, 

64 get_completions, 

65 longest_common_prefix, 

66 path_completion_prefix, 

67) 

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

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

70from lilbee.cli.tui.widgets.drawer import drawer_holding 

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

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

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

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

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

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

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

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

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

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

81from lilbee.core.config import cfg 

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

83from lilbee.core.config.model import CLEARABLE_MODEL_FIELDS 

84from lilbee.crawler import crawler_available, is_url, require_valid_crawl_url 

85from lilbee.data.store import ( 

86 ChunkType, 

87 EmbeddingModelMismatchError, 

88 SearchScope, 

89 scope_to_chunk_type, 

90) 

91from lilbee.providers.roles import WorkerRole 

92from lilbee.providers.warm_progress import WarmPhase, WarmProgress 

93from lilbee.retrieval.embedder import is_model_available 

94from lilbee.retrieval.query import SOURCES_BLOCK_MARKER, ChatMessage 

95from lilbee.retrieval.query.compaction import ( 

96 compaction_due, 

97 foldable, 

98 history_budget, 

99 overflow, 

100 prompt_history, 

101 summary_messages, 

102) 

103from lilbee.retrieval.query.history_window import estimate_tokens 

104from lilbee.retrieval.reasoning import RetrievalNotice 

105from lilbee.runtime import asyncio_loop 

106from lilbee.runtime.lock import ResetRefusedError 

107from lilbee.runtime.progress import ( 

108 EventType, 

109 ProgressEvent, 

110) 

111from lilbee.sessions import ( 

112 MessageRole, 

113 Session, 

114 SessionMessage, 

115 SessionNotFoundError, 

116 SessionOrigin, 

117 SessionOwnershipError, 

118 SessionStore, 

119 TitleSource, 

120 derive_title, 

121) 

122 

123if TYPE_CHECKING: 

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

125log = logging.getLogger(__name__) 

126 

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

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

129# message queue and makes key events visibly lag. 

130_STREAM_FLUSH_INTERVAL = 0.05 

131 

132 

133@dataclass 

134class _StreamTimings: 

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

136 

137 last_flush: float 

138 

139 

140@dataclass 

141class _Turn: 

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

143 

144 question: str 

145 widget: AssistantMessage 

146 generation: int 

147 session_id: str | None 

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

149 answer: str = "" 

150 

151 

152class _TurnEnded(Message): 

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

154 

155 bubble = False 

156 

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

158 super().__init__() 

159 self.turn = turn 

160 

161 

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

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

164_TURN_WORKER_GROUP = "chat_turn" 

165 

166 

167# ``/crawl`` command flags. 

168_CRAWL_FLAG_DEPTH = "--depth" 

169_CRAWL_FLAG_MAX_PAGES = "--max-pages" 

170_CRAWL_FLAG_INCLUDE_SUBDOMAINS = "--include-subdomains" 

171_CRAWL_FLAG_RENDER = "--render" 

172 

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

174# event loop. 

175_MODEL_SWAP_WORKER = "model_swap_reset" 

176 

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

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

179 

180 

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

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

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

184 from lilbee.catalog.formatting import display_label_for_ref 

185 

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

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

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

189 if snapshot.phase is WarmPhase.LOADING_ENGINE: 

190 return msg.ENGINE_ALMOST_READY 

191 return msg.ENGINE_WARMING 

192 

193 

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

195 

196 

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

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

199 if isinstance(exc, ConnectionError): 

200 return msg.STREAM_DISCONNECTED 

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

202 

203 

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

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

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

207 

208 

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

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

211 low = name.lower() 

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

213 if len(contains) == 1: 

214 return contains[0] 

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

216 return matches[0] if matches else None 

217 

218 

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

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

221 

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

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

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

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

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

227 """ 

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

229 if whole.exists(): 

230 return [whole] 

231 try: 

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

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

234 except ValueError: 

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

236 if os.name == "nt": 

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

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

239 

240 

241class ChatWelcome(Static): 

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

243 

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

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

246 

247 @staticmethod 

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

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

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

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

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

253 

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

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

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

257 self.update(self._body(hint)) 

258 

259 

260class PromptArea(Vertical): 

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

262 

263 pass 

264 

265 

266class ChatScreen(Screen[None]): 

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

268 

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

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

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

272 app: LilbeeApp # type: ignore[assignment] 

273 

274 CSS_PATH = "chat.tcss" 

275 AUTO_FOCUS = "#chat-input" 

276 

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

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

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

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

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

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

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

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

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

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

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

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

289 

290 HELP = ( 

291 "# Chat\n\n" 

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

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

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

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

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

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

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

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

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

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

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

303 ) 

304 

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

306 

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

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

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

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

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

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

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

314 

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

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

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

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

319 # F2 opens the searchable list of every slash command 

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

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

322 Binding( 

323 "f2", 

324 "show_command_catalog", 

325 "All commands", 

326 show=False, 

327 priority=True, 

328 ), 

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

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

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

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

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

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

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

336 # LilbeeApp.action_command_palette overriding to call 

337 # ChatScreen.action_complete_prev. Action is exposed for direct 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

360 # check_action below hides + disables it outside that exact 

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

362 # nothing is streaming. 

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

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

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

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

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

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

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

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

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

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

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

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

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

376 # after them follow it. 

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

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

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

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

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

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

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

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

385 # action_scroll_left raises SkipAction when there is nothing to scroll 

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

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

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

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

390 ] 

391 

392 def __init__(self) -> None: 

393 super().__init__() 

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

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

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

397 self._summary = "" 

398 self._history_lock = threading.Lock() 

399 # Which conversation _history holds; bumped under _history_lock whenever 

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

401 self._conversation_generation = 0 

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

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

404 # fresh session. 

405 self._session_id: str | None = None 

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

407 self._turn: _Turn | None = None 

408 self._insert_mode: bool = True 

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

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

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

412 self._suppress_refresh = 0 

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

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

415 self._completion_origin: str | None = None 

416 self._sync_active: bool = False 

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

418 self._history_index: int = -1 

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

420 # has to wait out a cold engine load. 

421 self._warm_tip_shown: bool = False 

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

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

424 # clears it. 

425 self._active_question: UserMessage | None = None 

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

427 self._model_switch_queued: bool = False 

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

429 

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

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

432 

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

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

435 """ 

436 from lilbee.cli.tui.command_registry import COMMANDS 

437 

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

439 for cmd in COMMANDS: 

440 method = getattr(self, cmd.handler) 

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

442 handlers[name] = method 

443 return handlers 

444 

445 @property 

446 def _task_bar(self) -> TaskBarController: 

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

448 return self.app.task_bar 

449 

450 def compose(self) -> ComposeResult: 

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

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

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

454 

455 with TopBars(): 

456 yield ViewTabs() 

457 yield VerticalScroll( 

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

459 id="chat-log", 

460 ) 

461 with BottomBars(): 

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

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

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

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

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

467 yield ChatInput( 

468 placeholder=msg.CHAT_INPUT_PLACEHOLDER_DEFAULT, 

469 id="chat-input", 

470 ) 

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

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

473 yield TaskBar() 

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

475 # prompt block its own row. 

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

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

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

479 yield Footer() 

480 

481 def on_mount(self) -> None: 

482 self._update_input_style() 

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

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

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

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

487 

488 def on_show(self) -> None: 

489 """Called when screen becomes visible.""" 

490 from lilbee.runtime.splash import dismiss 

491 

492 dismiss() 

493 self.refresh_model_bar() 

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

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

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

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

498 # so global bindings keep firing). 

499 with contextlib.suppress(Exception): 

500 if self._insert_mode: 

501 self._enter_insert_mode() 

502 else: 

503 self.set_focus(self._chat_log) 

504 

505 def _embedding_ready(self) -> bool: 

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

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

508 

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

510 key, _value = payload 

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

512 self.refresh_model_bar() 

513 

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

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

516 with contextlib.suppress(NoMatches): 

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

518 self._apply_input_busy_state() 

519 

520 def _enter_insert_mode(self) -> None: 

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

522 self._insert_mode = True 

523 self._chat_input.can_focus = True 

524 self.set_focus(self._chat_input) 

525 self._update_input_style() 

526 

527 def focus_prompt(self) -> None: 

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

529 self._enter_insert_mode() 

530 

531 def default_focus_target(self) -> Widget: 

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

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

534 

535 def action_focus_model_bar(self) -> None: 

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

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

538 

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

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

541 

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

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

544 """ 

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

546 

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

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

549 if self._reject_submit_when_busy(text): 

550 return 

551 self._handle_slash(text) 

552 

553 def _update_input_style(self) -> None: 

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

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

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

557 with contextlib.suppress(NoMatches): 

558 inp = self._chat_input 

559 if self._insert_mode: 

560 inp.remove_class("normal-mode") 

561 else: 

562 inp.add_class("normal-mode") 

563 self._update_mode_indicator() 

564 

565 def _update_mode_indicator(self) -> None: 

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

567 with contextlib.suppress(NoMatches): 

568 bar = self.query_one(ViewTabs) 

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

570 

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

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

573 inp = self._chat_input 

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

575 self.set_focus(inp) 

576 inp.insert(event.character) 

577 event.prevent_default() 

578 event.stop() 

579 

580 def action_insert_mode(self) -> None: 

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

582 self._enter_insert_mode() 

583 

584 def action_insert_or_send(self) -> None: 

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

586 self._enter_insert_mode() 

587 inp = self._chat_input 

588 if inp.value.strip(): 

589 self._submit_draft(inp, inp.value) 

590 

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

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

593 

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

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

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

597 dropping to INSERT. 

598 """ 

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

600 return False 

601 if action == "insert_or_send": 

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

603 return True 

604 

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

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

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

608 

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

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

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

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

613 the screen mode here. 

614 """ 

615 if not self._insert_mode: 

616 self._enter_insert_mode() 

617 

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

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

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

621 

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

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

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

625 """ 

626 if not self._insert_mode: 

627 self._enter_insert_mode() 

628 event.stop() 

629 

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

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

632 

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

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

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

636 """ 

637 if not self._insert_mode: 

638 return 

639 if event.widget is None: 

640 return 

641 chat_input = self._chat_input 

642 node: DOMNode | None = event.widget 

643 while node is not None: 

644 if node is chat_input: 

645 return 

646 node = node.parent 

647 self.action_enter_normal_mode() 

648 

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

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

651 if not self._insert_mode: 

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

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

654 self._enter_insert_mode() 

655 return 

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

657 

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

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

660 text = value.strip() 

661 if not self._ready_to_submit(text): 

662 return 

663 chat_input.value = "" 

664 self._input_history.append(text) 

665 self._history_index = -1 

666 

667 if text.startswith("/"): 

668 self._handle_slash(text) 

669 return 

670 self._send_message(text) 

671 

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

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

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

675 return False 

676 cmd = self._slash_name(text) 

677 if cmd: 

678 if cmd not in self._command_handlers: 

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

680 # fixed in place instead of retyped. 

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

682 return False 

683 return True 

684 pending = self._pending_required_model_download() 

685 if pending is not None: 

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

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

688 self.notify( 

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

690 severity="warning", 

691 timeout=5, 

692 ) 

693 return False 

694 return True 

695 

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

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

698 

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

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

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

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

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

704 """ 

705 if self.swapping_model: 

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

707 return True 

708 if self.reloading_placement: 

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

710 return True 

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

712 self._notify_busy() 

713 return True 

714 return False 

715 

716 def _notify_busy(self) -> None: 

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

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

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

720 

721 @staticmethod 

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

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

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

725 

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

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

728 

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

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

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

732 """ 

733 task_bar = self.app.task_bar 

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

735 label = task_bar.downloading_label_for(ref) 

736 if label is not None: 

737 return label 

738 return None 

739 

740 def _dismiss_overlay_on_submit(self) -> bool: 

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

742 

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

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

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

746 a submission. 

747 """ 

748 overlay = self._completion_overlay 

749 if overlay.is_visible: 

750 overlay.hide() 

751 self._completion_origin = None 

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

753 self._set_input("") 

754 return True 

755 return False 

756 

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

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

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

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

761 handler = self._command_handlers.get(cmd) 

762 if handler is not None: 

763 handler(args) 

764 else: 

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

766 

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

768 if streaming: 

769 self._enter_streaming_state() 

770 else: 

771 self._exit_streaming_state() 

772 

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

774 self.refresh_bindings() 

775 

776 def _enter_streaming_state(self) -> None: 

777 self.add_class("streaming") 

778 self.refresh_bindings() 

779 

780 def _exit_streaming_state(self) -> None: 

781 self.remove_class("streaming") 

782 self.refresh_bindings() 

783 if self._model_switch_queued: 

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

785 self._model_switch_queued = False 

786 self.apply_model_change() 

787 

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

789 from lilbee.app.ingest import source_label_taken 

790 

791 if not args: 

792 return 

793 if self._sync_active: 

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

795 return 

796 if is_url(args): 

797 self._cmd_crawl(args) 

798 return 

799 paths = _parse_add_paths(args) 

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

801 if missing: 

802 self.notify( 

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

804 severity="error", 

805 ) 

806 return 

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

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

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

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

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

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

813 if duplicates: 

814 self._prompt_overwrite(paths, duplicates) 

815 return 

816 self._submit_add(paths, force=False) 

817 

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

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

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

821 

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

823 

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

825 if not confirmed: 

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

827 return 

828 self._submit_add(paths, force=True) 

829 

830 self.app.push_screen( 

831 ConfirmDialog( 

832 msg.CMD_ADD_DUPLICATE_TITLE, 

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

834 ), 

835 _on_confirm, 

836 ) 

837 

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

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

840 from lilbee.cli.tui.task_queue import TaskType 

841 

842 self._sync_active = True 

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

844 

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

846 try: 

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

848 finally: 

849 self._sync_active = False 

850 

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

852 

853 def _do_add( 

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

855 ) -> None: 

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

857 from lilbee.app.ingest import register_sources 

858 from lilbee.data.ingest import sync 

859 

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

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

862 reg_result = register_sources(paths, force=force) 

863 registered = reg_result.registered 

864 for name in reg_result.name_taken: 

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

866 if reg_result.tracked: 

867 call_from_thread( 

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

869 ) 

870 if reg_result.overlapping: 

871 names = ", ".join(reg_result.overlapping) 

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

873 if not reg_result.reached_corpus: 

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

875 return 

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

877 

878 try: 

879 sync_result = asyncio_loop.run( 

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

881 ) 

882 except BaseException: 

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

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

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

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

887 unregister_added_roots(registered) 

888 raise 

889 if sync_result.failed: 

890 unregister_added_roots(registered) 

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

892 if sync_result.skipped: 

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

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

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

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

897 unregister_added_roots(registered) 

898 raise RuntimeError(skipped_msg) 

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

900 if sync_result.relocated: 

901 call_from_thread( 

902 self, 

903 self.notify, 

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

905 ) 

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

907 

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

909 self._stop_turn() 

910 for worker in self.workers: 

911 if worker.group != _TURN_WORKER_GROUP: 

912 worker.cancel() 

913 self.notify(msg.CMD_CANCEL) 

914 

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

916 self._reset_conversation() 

917 self.notify(msg.CMD_CLEAR) 

918 

919 def _reset_conversation(self) -> None: 

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

921 

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

923 next user turn open a fresh one. 

924 """ 

925 self._stop_turn() 

926 self._chat_log.remove_children() 

927 self._active_question = None 

928 with self._history_lock: 

929 self._history.clear() 

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

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

932 self._summary = "" 

933 self._conversation_generation += 1 

934 self._session_id = None 

935 

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

937 if not crawler_available(): 

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

939 return 

940 if not args: 

941 self._open_crawl_dialog() 

942 return 

943 parts = args.split() 

944 url = parts[0] 

945 if not is_url(url): 

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

947 try: 

948 require_valid_crawl_url(url) 

949 except ValueError as exc: 

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

951 return 

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

953 self._start_crawl( 

954 url, 

955 depth, 

956 max_pages, 

957 include_subdomains=include_subdomains, 

958 render_mode=render_mode, 

959 ) 

960 

961 def _open_crawl_dialog(self) -> None: 

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

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

964 

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

966 if result is not None: 

967 self._start_crawl( 

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

969 ) 

970 

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

972 

973 def _start_crawl( 

974 self, 

975 url: str, 

976 depth: int | None, 

977 max_pages: int | None, 

978 *, 

979 include_subdomains: bool = False, 

980 render_mode: CrawlRenderMode | None = None, 

981 ) -> None: 

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

983 

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

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

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

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

988 sticks for the next crawl. 

989 """ 

990 from lilbee.cli.tui.task_queue import TaskType 

991 

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

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

994 self._persist_crawl_render_mode(render_mode) 

995 

996 def _kick_off_crawl() -> None: 

997 self._task_bar.start_task( 

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

999 TaskType.CRAWL, 

1000 lambda reporter: self._do_crawl( 

1001 url, 

1002 depth, 

1003 max_pages, 

1004 reporter, 

1005 include_subdomains=include_subdomains, 

1006 render_mode=mode, 

1007 ), 

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

1009 ) 

1010 

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

1012 if mode is CrawlRenderMode.BROWSER: 

1013 self._task_bar.ensure_chromium(_kick_off_crawl) 

1014 else: 

1015 _kick_off_crawl() 

1016 

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

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

1019 from lilbee.app.settings import apply_settings_update 

1020 

1021 try: 

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

1023 except (ValueError, OSError) as exc: 

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

1025 

1026 @staticmethod 

1027 def _parse_crawl_flags( 

1028 tokens: list[str], 

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

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

1031 

1032 Numeric flags return None when absent so the caller inherits 

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

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

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

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

1037 """ 

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

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

1040 include_subdomains = False 

1041 render_mode: CrawlRenderMode | None = None 

1042 i = 0 

1043 while i < len(tokens): 

1044 if tokens[i] == _CRAWL_FLAG_INCLUDE_SUBDOMAINS: 

1045 include_subdomains = True 

1046 i += 1 

1047 continue 

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

1049 with contextlib.suppress(ValueError): 

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

1051 i += 2 

1052 continue 

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

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

1055 with contextlib.suppress(ValueError): 

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

1057 i += 2 

1058 else: 

1059 i += 1 

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

1061 

1062 def _do_crawl( 

1063 self, 

1064 url: str, 

1065 depth: int | None, 

1066 max_pages: int | None, 

1067 reporter: ProgressReporter, 

1068 *, 

1069 include_subdomains: bool = False, 

1070 render_mode: CrawlRenderMode | None = None, 

1071 ) -> None: 

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

1073 from lilbee.crawler import crawl_and_save 

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

1075 

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

1077 failures: list[str] = [] 

1078 

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

1080 if event_type == EventType.SETUP_START: 

1081 reporter.update(0, msg.SETUP_CHROMIUM_NAME) 

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

1083 if data.total_bytes: 

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

1085 detail = msg.SETUP_CHROMIUM_DETAIL.format( 

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

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

1088 ) 

1089 else: 

1090 pct = 0 

1091 detail = msg.SETUP_CHROMIUM_DETAIL_UNKNOWN.format( 

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

1093 ) 

1094 reporter.update(pct, detail) 

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

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

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

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

1099 # soon as the total is known. 

1100 if data.total > 0: 

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

1102 reporter.update( 

1103 pct, 

1104 msg.CMD_CRAWL_PAGE.format( 

1105 current=data.current, total=data.total, url=data.url 

1106 ), 

1107 indeterminate=False, 

1108 ) 

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

1110 reporter.update( 

1111 0, 

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

1113 indeterminate=True, 

1114 ) 

1115 elif event_type == EventType.CRAWL_PAGE_FAILED and isinstance( 

1116 data, CrawlPageFailedEvent 

1117 ): 

1118 failures.append(data.reason) 

1119 

1120 paths = asyncio_loop.run( 

1121 crawl_and_save( 

1122 url, 

1123 depth=depth, 

1124 max_pages=max_pages, 

1125 on_progress=on_progress, 

1126 quiet=True, 

1127 include_subdomains=include_subdomains, 

1128 render_mode=render_mode, 

1129 ) 

1130 ) 

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

1132 if failures: 

1133 call_from_thread( 

1134 self, 

1135 self.notify, 

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

1137 severity="warning", 

1138 ) 

1139 

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

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

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

1143 self.app.switch_view(msg.CATALOG_VIEW) 

1144 

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

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

1147 del args 

1148 self._run_sync(prune_ignored=True) 

1149 

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

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

1152 self._cmd_delete_worker(args.strip()) 

1153 

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

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

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

1157 try: 

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

1159 except Exception: 

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

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

1162 return 

1163 

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

1165 if not known: 

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

1167 return 

1168 

1169 if not name: 

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

1171 call_from_thread(self, self.notify, usage) 

1172 return 

1173 

1174 if name not in known: 

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

1176 suggestion = _closest_source(name, known) 

1177 if suggestion is not None: 

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

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

1180 return 

1181 

1182 remove_documents_durably([name]) 

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

1184 

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

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

1187 path = args.strip() 

1188 if not path: 

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

1190 return 

1191 from lilbee.cli.tui.task_queue import TaskType 

1192 

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

1194 self._do_export(path, reporter) 

1195 

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

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

1198 

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

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

1201 from lilbee.app.dataset import DatasetError, export_to_path 

1202 

1203 output = Path(raw_path).expanduser() 

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

1205 try: 

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

1207 except DatasetError as exc: 

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

1209 raise RuntimeError(str(exc)) from exc 

1210 call_from_thread( 

1211 self, 

1212 self.notify, 

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

1214 ) 

1215 

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

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

1218 path = args.strip() 

1219 if not path: 

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

1221 return 

1222 if self._sync_active: 

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

1224 return 

1225 from lilbee.cli.tui.task_queue import TaskType 

1226 

1227 self._sync_active = True 

1228 

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

1230 try: 

1231 self._do_import(path, reporter) 

1232 finally: 

1233 self._sync_active = False 

1234 self._task_bar.start_detect_pending() 

1235 

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

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

1238 

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

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

1241 from lilbee.app.dataset import DatasetError, import_from_path 

1242 

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

1244 try: 

1245 summary = asyncio_loop.run( 

1246 import_from_path( 

1247 Path(raw_path).expanduser(), 

1248 "", 

1249 on_progress=build_import_progress_callback(reporter), 

1250 ) 

1251 ) 

1252 except DatasetError as exc: 

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

1254 raise RuntimeError(str(exc)) from exc 

1255 call_from_thread( 

1256 self, 

1257 self.notify, 

1258 msg.CMD_IMPORT_SUCCESS.format( 

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

1260 ), 

1261 ) 

1262 

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

1264 self.action_show_command_catalog() 

1265 

1266 def action_show_command_catalog(self) -> None: 

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

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

1269 

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

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

1272 self._enter_insert_mode() 

1273 inp = self._chat_input 

1274 inp.value = f"{name} " 

1275 inp.action_end() 

1276 

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

1278 if name is None: 

1279 return 

1280 self.insert_slash_command(name) 

1281 

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

1283 token = args.strip() 

1284 if not token: 

1285 import webbrowser 

1286 

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

1288 self.notify(msg.CHAT_LOGIN_PROMPT) 

1289 return 

1290 self._run_hf_login(token) 

1291 

1292 @work(thread=True) 

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

1294 try: 

1295 from huggingface_hub import login 

1296 

1297 login(token=token, add_to_git_credential=False) 

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

1299 except Exception as exc: 

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

1301 call_from_thread( 

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

1303 ) 

1304 

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

1306 if args: 

1307 from lilbee.catalog.formatting import display_label_for_ref 

1308 

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

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

1311 self.notify( 

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

1313 ) 

1314 self.apply_model_change() 

1315 self.refresh_model_bar() 

1316 else: 

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

1318 

1319 self.app.push_screen(CatalogScreen()) 

1320 

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

1322 self.app.exit() 

1323 

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

1325 name = args.strip() 

1326 if not name: 

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

1328 return 

1329 self._run_remove_model(name) 

1330 

1331 @work(thread=True) 

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

1333 mgr = get_services().model_manager 

1334 try: 

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

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

1337 if mgr.remove(name): 

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

1339 else: 

1340 call_from_thread( 

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

1342 ) 

1343 except Exception: 

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

1345 call_from_thread( 

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

1347 ) 

1348 

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

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

1351 

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

1353 if not confirmed: 

1354 return 

1355 self._run_sync(force_rebuild=True) 

1356 

1357 self.app.push_screen( 

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

1359 _on_confirm, 

1360 ) 

1361 

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

1363 self.request_reset() 

1364 

1365 def request_reset(self) -> None: 

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

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

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

1369 

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

1371 if not confirmed: 

1372 return 

1373 from lilbee.app.reset import perform_reset 

1374 

1375 try: 

1376 result = perform_reset() 

1377 except ResetRefusedError as exc: 

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

1379 return 

1380 except Exception as exc: 

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

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

1383 return 

1384 

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

1386 reset_store() 

1387 

1388 if result.skipped: 

1389 self.notify( 

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

1391 severity="warning", 

1392 ) 

1393 else: 

1394 self.notify(msg.CMD_RESET_SUCCESS) 

1395 

1396 self.app.push_screen( 

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

1398 _on_confirm, 

1399 ) 

1400 

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

1402 if not args: 

1403 return 

1404 parts = args.split(None, 1) 

1405 key = parts[0] 

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

1407 

1408 if key not in SETTINGS_MAP: 

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

1410 return 

1411 

1412 defn = SETTINGS_MAP[key] 

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

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

1415 return 

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

1417 try: 

1418 parsed: object 

1419 if key in CLEARABLE_MODEL_FIELDS: 

1420 parsed = "" if clears else value 

1421 elif defn.type is bool: 

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

1423 elif clears: 

1424 parsed = None 

1425 else: 

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

1427 self.notify( 

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

1429 severity="error", 

1430 ) 

1431 return 

1432 try: 

1433 parsed = defn.type(value) 

1434 except (ValueError, TypeError): 

1435 self.notify( 

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

1437 severity="error", 

1438 ) 

1439 return 

1440 # Route through set_setting so settings_changed_signal subscribers 

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

1442 # _invalidate_caches now handles llm_provider service reset. 

1443 self.app.set_setting(key, parsed) 

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

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

1446 except (ValueError, TypeError) as exc: 

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

1448 

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

1450 self.app.switch_view("Settings") 

1451 

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

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

1454 self._cmd_remember_worker(args) 

1455 

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

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

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

1459 outcome = remember_from_input(raw) 

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

1461 

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

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

1464 

1465 self.app.push_screen(MemoriesScreen()) 

1466 

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

1468 self.app.switch_view("Status") 

1469 

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

1471 if not args: 

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

1473 self.insert_slash_command("/theme") 

1474 return 

1475 if args not in DARK_THEMES: 

1476 self.notify( 

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

1478 severity="warning", 

1479 ) 

1480 return 

1481 self.app.set_theme(args) 

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

1483 

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

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

1486 

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

1488 if not cfg.wiki: 

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

1490 return 

1491 self.app.switch_view("Wiki") 

1492 

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

1494 self.app.call_later(self.app.action_toggle_sessions) 

1495 

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

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

1498 if not cfg.sessions_enabled: 

1499 self.app.notify_sessions_disabled() 

1500 return None 

1501 if self._session_id is None: 

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

1503 return None 

1504 try: 

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

1506 except SessionNotFoundError: 

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

1508 return None 

1509 

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

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

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

1513 if source is None: 

1514 return 

1515 points = fork_points(source.messages) 

1516 if not points: 

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

1518 return 

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

1520 

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

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

1523 session = self._read_active_session( 

1524 msg.EXPORT_CHAT_NO_SESSION, msg.EXPORT_CHAT_SESSION_GONE 

1525 ) 

1526 if session is None: 

1527 return 

1528 try: 

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

1530 except OSError as exc: 

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

1532 return 

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

1534 

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

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

1537 if message_count is None: 

1538 return 

1539 try: 

1540 fork_id = get_services().session_store.fork( 

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

1542 ) 

1543 except SessionNotFoundError: 

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

1545 return 

1546 except SessionOwnershipError as exc: 

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

1548 return 

1549 fork = self._load_session(fork_id) 

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

1551 self._set_input("") 

1552 self._enter_insert_mode() 

1553 

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

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

1556 from textual.css.query import NoMatches 

1557 

1558 if self.streaming: 

1559 self._notify_busy() 

1560 return 

1561 log = self._chat_log 

1562 with contextlib.suppress(NoMatches): 

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

1564 question = UserMessage(text) 

1565 log.mount(question) 

1566 self._active_question = question 

1567 

1568 # The assistant bubble owns its own ThinkingHeader animator until 

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

1570 assistant_msg = AssistantMessage() 

1571 log.mount(assistant_msg) 

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

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

1574 log.anchor() 

1575 

1576 with self._history_lock: 

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

1578 self._persist_user_turn(text) 

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

1580 self._turn = turn 

1581 self.streaming = True 

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

1583 

1584 def _current_scope_value(self) -> str: 

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

1586 from textual.css.query import NoMatches 

1587 

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

1589 

1590 try: 

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

1592 except NoMatches: 

1593 return SearchScope.BOTH.value 

1594 return chip.scope 

1595 

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

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

1598 

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

1600 otherwise. 

1601 """ 

1602 return scope_to_chunk_type(self._current_scope_value()) 

1603 

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

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

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

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

1608 self._session_id = session_id 

1609 return session_id 

1610 

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

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

1613 if not cfg.sessions_enabled: 

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

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

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

1617 return 

1618 store = get_services().session_store 

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

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

1621 try: 

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

1623 except SessionNotFoundError: 

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

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

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

1627 

1628 def _save_assistant_turn( 

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

1630 ) -> None: 

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

1632 try: 

1633 self._persist_assistant_turn(session_id, content, sources) 

1634 except (OSError, SessionOwnershipError) as exc: 

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

1636 call_from_thread( 

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

1638 ) 

1639 

1640 def _persist_assistant_turn( 

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

1642 ) -> None: 

1643 """Append the assistant turn to *session_id*, the session its turn started in.""" 

1644 if session_id is None or not cfg.sessions_enabled: 

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

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

1647 # relying on _persist_user_turn having left the id unset. 

1648 return 

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

1650 with contextlib.suppress(SessionNotFoundError): 

1651 get_services().session_store.add_message( 

1652 session_id, 

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

1654 surface=SessionOrigin.TUI, 

1655 ) 

1656 

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

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

1659 session = self._load_session(session_id) 

1660 self.focus_prompt() 

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

1662 

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

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

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

1666 self._reset_conversation() 

1667 self._session_id = session_id 

1668 for message in session.messages: 

1669 self._render_restored_message(message) 

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

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

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

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

1674 # what a resumed conversation must not do. 

1675 loaded: list[ChatMessage] = [ 

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

1677 ] 

1678 with self._history_lock: 

1679 self._history = loaded 

1680 self._summary = session.summary 

1681 self._restore_session_model(session.meta.model_ref) 

1682 self._refresh_context_usage() 

1683 self._chat_log.scroll_end(animate=False) 

1684 return session 

1685 

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

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

1688 

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

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

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

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

1693 """ 

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

1695 return 

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

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

1698 else: 

1699 self.notify( 

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

1701 severity="warning", 

1702 ) 

1703 

1704 @property 

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

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

1707 return self._session_id 

1708 

1709 def start_new_conversation(self) -> None: 

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

1711 self._reset_conversation() 

1712 self.focus_prompt() 

1713 self.notify(msg.SESSIONS_NEW) 

1714 

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

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

1717 log = self._chat_log 

1718 if message.role == MessageRole.USER: 

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

1720 return 

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

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

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

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

1725 

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

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

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

1729 try: 

1730 self._do_stream_response(turn, chunk_type) 

1731 finally: 

1732 post_from_thread(self, _TurnEnded(turn)) 

1733 

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

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

1736 response_parts: list[str] = [] 

1737 stream: Any = None 

1738 try: 

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

1740 return 

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

1742 if self._turn_stopped(turn): 

1743 return 

1744 with self._history_lock: 

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

1746 recent = self._history[:-1] 

1747 summary = self._summary 

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

1749 stream = get_services().searcher.ask_stream( 

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

1751 ) 

1752 self._consume_stream(stream, turn, response_parts) 

1753 except EmbeddingModelMismatchError as exc: 

1754 with contextlib.suppress(Exception): 

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

1756 except Exception as exc: 

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

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

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

1760 if not self._turn_stopped(turn): 

1761 with contextlib.suppress(Exception): 

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

1763 finally: 

1764 close_stream(stream) 

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

1766 self._save_turn(turn) 

1767 

1768 @staticmethod 

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

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

1771 if turn.stop.is_set(): 

1772 return True 

1773 try: 

1774 return _get_worker().is_cancelled 

1775 except NoActiveWorker: 

1776 return False 

1777 

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

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

1780 

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

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

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

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

1785 already rendered into the bubble. 

1786 """ 

1787 from lilbee.app.placement import ( 

1788 chat_engine_ready, 

1789 chat_warm_error, 

1790 request_engine_warm, 

1791 wait_chat_ready, 

1792 ) 

1793 

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

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

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

1797 # lazily rebuilding the way ask_stream always has. 

1798 get_services() 

1799 if chat_engine_ready(): 

1800 return True 

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

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

1803 request_engine_warm() 

1804 self._show_warm_tip_once() 

1805 widget = turn.widget 

1806 

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

1808 with contextlib.suppress(Exception): 

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

1810 

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

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

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

1814 with contextlib.suppress(Exception): 

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

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

1817 with contextlib.suppress(Exception): 

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

1819 return True 

1820 if self._turn_stopped(turn): 

1821 return False 

1822 error = chat_warm_error() 

1823 text = ( 

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

1825 if error is not None 

1826 else msg.ENGINE_NOT_READY 

1827 ) 

1828 with contextlib.suppress(Exception): 

1829 call_from_thread(self, widget.append_content, text) 

1830 return False 

1831 

1832 def _show_warm_tip_once(self) -> None: 

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

1834 if cfg.keep_engine_warm or self._warm_tip_shown: 

1835 return 

1836 self._warm_tip_shown = True 

1837 with contextlib.suppress(Exception): 

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

1839 

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

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

1842 

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

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

1845 """ 

1846 from lilbee.app.memory import auto_extract_enabled 

1847 

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

1849 return 

1850 self._extract_memories_worker(question, answer) 

1851 

1852 def _indexing_active(self) -> bool: 

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

1854 

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

1856 re-indexes the page it publishes. 

1857 """ 

1858 from lilbee.cli.tui.task_queue import TaskType 

1859 

1860 busy = { 

1861 TaskType.SYNC.value, 

1862 TaskType.ADD.value, 

1863 TaskType.IMPORT.value, 

1864 TaskType.WIKI.value, 

1865 } 

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

1867 

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

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

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

1871 from lilbee.app.memory import auto_extract 

1872 

1873 stored = auto_extract(question, answer) 

1874 if stored: 

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

1876 

1877 def _on_embedding_mismatch( 

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

1879 ) -> None: 

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

1881 if not exc.dims_match: 

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

1883 return 

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

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

1886 

1887 self.app.push_screen( 

1888 ConfirmDialog( 

1889 msg.EMBED_ADOPT_CONFIRM_TITLE, 

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

1891 ), 

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

1893 ) 

1894 

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

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

1897 if not confirmed: 

1898 self.notify(msg.EMBED_ADOPT_CANCELLED) 

1899 return 

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

1901 self._adopt_and_retry(ref, question) 

1902 

1903 @work(thread=True) 

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

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

1906 self._do_adopt_and_retry(ref, question) 

1907 

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

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

1910 from lilbee.app.models import adopt_embedder 

1911 

1912 try: 

1913 adopt_embedder(ref) 

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

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

1916 call_from_thread( 

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

1918 ) 

1919 return 

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

1921 call_from_thread(self, self._send_message, question) 

1922 

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

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

1925 widget = turn.widget 

1926 reason_buf: list[str] = [] 

1927 content_buf: list[str] = [] 

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

1929 

1930 def flush() -> None: 

1931 if reason_buf: 

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

1933 reason_buf.clear() 

1934 if content_buf: 

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

1936 content_buf.clear() 

1937 

1938 for token in stream: 

1939 if self._turn_stopped(turn): 

1940 break 

1941 try: 

1942 if isinstance(token, RetrievalNotice): 

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

1944 call_from_thread(self, widget.set_thinking_status, status) 

1945 continue 

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

1947 self._maybe_flush(flush, timings) 

1948 except Exception: 

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

1950 with contextlib.suppress(Exception): 

1951 flush() 

1952 

1953 @staticmethod 

1954 def _buffer_token( 

1955 token: Any, 

1956 reason_buf: list[str], 

1957 content_buf: list[str], 

1958 response_parts: list[str], 

1959 ) -> None: 

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

1961 if token.is_reasoning: 

1962 reason_buf.append(token.content) 

1963 elif token.content: 

1964 response_parts.append(token.content) 

1965 content_buf.append(token.content) 

1966 

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

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

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

1970 """ 

1971 now = time.monotonic() 

1972 if now - timings.last_flush >= _STREAM_FLUSH_INTERVAL: 

1973 flush() 

1974 timings.last_flush = now 

1975 

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

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

1978 

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

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

1981 """ 

1982 if not turn.answer: 

1983 return 

1984 with self._history_lock: 

1985 # A resume or /clear mid-answer replaced the conversation; the 

1986 # answer belongs to the one that is gone. 

1987 if turn.generation == self._conversation_generation: 

1988 self._history.append({"role": "assistant", "content": turn.answer}) 

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

1990 # trimming now would drop turns without folding them in. 

1991 self._save_assistant_turn(turn.session_id, turn.answer, []) 

1992 

1993 @on(_TurnEnded) 

1994 def _on_turn_ended(self, event: _TurnEnded) -> None: 

1995 """Drop the busy flag first, then finish the turn's bubble if it is on screen.""" 

1996 turn = event.turn 

1997 self._turn = None 

1998 self.stopping = False 

1999 self.streaming = False 

2000 self._maybe_extract_memories(turn.question, turn.answer) 

2001 if turn.generation == self._conversation_generation: 

2002 self._show_turn_end(turn) 

2003 

2004 def _show_turn_end(self, turn: _Turn) -> None: 

2005 """Finish *turn*'s bubble, its cancel note, the context chip and the search toast.""" 

2006 if turn.stop.is_set(): 

2007 turn.widget.append_content(msg.STREAM_CANCELLED) 

2008 turn.widget.finish() 

2009 if not turn.answer: 

2010 return 

2011 self._refresh_context_usage() 

2012 if ( 

2013 cfg.chat_mode == ChatMode.SEARCH.value 

2014 and self._embedding_ready() 

2015 and SOURCES_BLOCK_MARKER not in turn.answer 

2016 ): 

2017 self._notify_no_results() 

2018 

2019 def _notify_no_results(self) -> None: 

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

2021 

2022 @staticmethod 

2023 def _history_budget() -> int: 

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

2025 return history_budget(cfg.chat_n_ctx_target) 

2026 

2027 def _compact_history(self, session_id: str | None, generation: int) -> None: 

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

2029 

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

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

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

2033 

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

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

2036 user sends another turn meanwhile. The result applies only while 

2037 *generation* is still the conversation on screen, and the summary is 

2038 saved to *session_id*, the session the turn started in. 

2039 """ 

2040 with self._history_lock: 

2041 history = list(self._history) 

2042 summary = self._summary 

2043 budget = self._history_budget() 

2044 if not cfg.chat_compaction: 

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

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

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

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

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

2050 if not dropped: 

2051 return 

2052 with self._history_lock: 

2053 if generation != self._conversation_generation: 

2054 return 

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

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

2057 return 

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

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

2060 return 

2061 dropped = foldable(history) 

2062 if not dropped: 

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

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

2065 # it instead. 

2066 return 

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

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

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

2070 call_from_thread(self, self._set_compacting, True) 

2071 try: 

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

2073 finally: 

2074 call_from_thread(self, self._set_compacting, False) 

2075 with self._history_lock: 

2076 if generation != self._conversation_generation: 

2077 # A resume or /clear replaced the conversation during the model 

2078 # call; the fold belongs to the one that is gone. 

2079 return 

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

2081 self._summary = result.summary 

2082 if session_id and result.summary and cfg.sessions_enabled: 

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

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

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

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

2087 with contextlib.suppress(SessionNotFoundError): 

2088 get_services().session_store.set_summary(session_id, result.summary) 

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

2090 

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

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

2093 with contextlib.suppress(NoMatches): 

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

2095 

2096 def _refresh_context_usage(self) -> None: 

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

2098 

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

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

2101 """ 

2102 with self._history_lock: 

2103 history = list(self._history) 

2104 summary = self._summary 

2105 budget = self._history_budget() 

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

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

2108 with contextlib.suppress(NoMatches): 

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

2110 

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

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

2113 

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

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

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

2117 user has navigated off the chat screen. 

2118 """ 

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

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

2121 anchor = self._active_question 

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

2123 anchor = None 

2124 with contextlib.suppress(NoMatches): 

2125 for title in titles: 

2126 rule = Static( 

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

2128 classes="compaction-marker", 

2129 ) 

2130 if anchor is None: 

2131 self._chat_log.mount(rule) 

2132 else: 

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

2134 

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

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

2137 

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

2139 consistently, different words because nothing was summarized. 

2140 """ 

2141 self._refresh_context_usage() 

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

2143 with contextlib.suppress(NoMatches): 

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

2145 

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

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

2148 

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

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

2151 

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

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

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

2155 """ 

2156 self._refresh_context_usage() 

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

2158 if stranded: 

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

2160 self._mark_context_boundary(*titles) 

2161 with contextlib.suppress(NoMatches): 

2162 self.notify( 

2163 msg.CHAT_COMPACTED_STRANDED_TOAST if stranded else msg.CHAT_COMPACTED_TOAST, 

2164 severity="warning", 

2165 ) 

2166 

2167 def action_scroll_up(self) -> None: 

2168 self._chat_log.scroll_page_up() 

2169 

2170 def action_scroll_down(self) -> None: 

2171 self._chat_log.scroll_page_down() 

2172 

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

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

2175 

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

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

2178 """ 

2179 if action == "cancel_stream": 

2180 return self.streaming and not self.stopping and self._insert_mode 

2181 if action in _MODE_ACTIONS: 

2182 # A focused drawer keeps the mode keys for its own bindings. 

2183 if self._focus_in_drawer(): 

2184 return False 

2185 return action == "enter_normal_mode" or self._leaves_normal_mode(action) 

2186 if action == "enter_model_strip": 

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

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

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

2190 # letters without having to be named here. 

2191 focused = self.focused 

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

2193 return super().check_action(action, parameters) 

2194 

2195 def action_enter_normal_mode(self) -> None: 

2196 """Esc dismisses the overlay if visible; otherwise drops into NORMAL mode.""" 

2197 overlay = self._completion_overlay 

2198 if overlay.is_visible: 

2199 # Revert any previewed candidate back to what the user typed. 

2200 if self._completion_origin is not None and self._chat_input.value != ( 

2201 self._completion_origin 

2202 ): 

2203 self._set_input(self._completion_origin) 

2204 self._completion_origin = None 

2205 overlay.hide() 

2206 # Backing out of the command list leaves nothing worth keeping in 

2207 # a lone slash, and it would hijack the next message as /word. 

2208 if self._chat_input.value.strip() == "/": 

2209 self._set_input("") 

2210 return 

2211 if isinstance(self.focused, Select) or self._focus_in_model_bar(): 

2212 # Leaving the model strip should put us back in INSERT so the 

2213 # user can type their next prompt; routing through the helper 

2214 # makes sure can_focus is re-enabled. 

2215 self._enter_insert_mode() 

2216 return 

2217 self._insert_mode = False 

2218 # Make the chat input unfocusable in NORMAL mode so Tab traversal 

2219 # skips past it AND a programmatic focus restore (modal close, 

2220 # screen pop) cannot land on it. The user re-enters INSERT 

2221 # explicitly via i/a/o/Enter or by clicking the input. 

2222 self._chat_input.can_focus = False 

2223 self.set_focus(self._chat_log) 

2224 self._update_input_style() 

2225 

2226 def action_cancel_stream(self) -> None: 

2227 """Stop the live answer. Bound to Ctrl+C from INSERT mode.""" 

2228 self._stop_turn() 

2229 

2230 def _stop_turn(self) -> None: 

2231 """Ask the live turn to stop and sever its inference call. 

2232 

2233 The body sees the stop at the engine wait, before and after the fold, 

2234 between tokens and in its error branch; ``cancel_inference`` severs the 

2235 stream's transport to unblock a reader stuck in a socket read. The turn 

2236 stays busy until its worker ends it. 

2237 """ 

2238 turn = self._turn 

2239 if turn is None: 

2240 return 

2241 turn.stop.set() 

2242 get_services().cancel_inference() 

2243 self.stopping = True 

2244 

2245 def apply_model_change(self) -> None: 

2246 """Swap to the new chat model without freezing the UI or losing an answer. 

2247 

2248 Reloading the fleet for the new model is a multi-second restart, so it 

2249 runs in a thread worker instead of on the event loop. The worker reloads 

2250 only the chat role; the provider retires any still-busy client across the 

2251 restart and serializes overlapping reloads, so the worker can start at 

2252 once without waiting for other workers. 

2253 

2254 A switch requested mid-answer is queued, not applied: restarting the chat 

2255 server under a live stream kills the answer being read. The queued switch 

2256 runs on leaving the streaming state, so it covers a finished, cancelled 

2257 and cleared answer alike. 

2258 """ 

2259 if self.swapping_model: 

2260 # A swap is already loading; a second one (rapid /model, or the model 

2261 # bar re-clicked while the input is disabled) would spawn a duplicate 

2262 # worker and a duplicate completion toast. The in-flight reload already 

2263 # coalesces onto the latest cfg, so ignore the re-entry. 

2264 self.notify(msg.CHAT_MODEL_SWITCHING, severity="warning", timeout=3) 

2265 return 

2266 if self.streaming: 

2267 from lilbee.catalog.formatting import display_label_for_ref 

2268 

2269 # cfg already holds the new ref, so a second queued switch needs no 

2270 # extra state. 

2271 self._model_switch_queued = True 

2272 self.app.notify( 

2273 msg.MODEL_SWAP_QUEUED.format(name=display_label_for_ref(cfg.chat_model)) 

2274 ) 

2275 return 

2276 self.swapping_model = True 

2277 self.app.notify(msg.MODEL_SWAP_APPLYING) 

2278 self._reload_chat_model_worker() 

2279 

2280 def _apply_input_busy_state(self) -> None: 

2281 """Disable the chat input while a swap or placement reload is loading, and 

2282 say why in the placeholder so a person is never left facing a dead input 

2283 with no explanation. 

2284 

2285 Restores focus and the default placeholder when the fleet is idle again so 

2286 the user can type without re-clicking the input that was disabled out from 

2287 under them. Guarded because the unblock can fire (via ``call_from_thread`` 

2288 or a bubbled message) after the user navigated away and the input is no 

2289 longer mounted. 

2290 """ 

2291 no_model = not self.app.chat_is_ready 

2292 busy = self.swapping_model or self.reloading_placement or no_model 

2293 with contextlib.suppress(NoMatches): 

2294 inp = self._chat_input 

2295 inp.disabled = busy 

2296 if no_model: 

2297 inp.placeholder = msg.CHAT_INPUT_NO_MODEL 

2298 elif self.swapping_model: 

2299 from lilbee.catalog.formatting import display_label_for_ref 

2300 

2301 inp.placeholder = msg.CHAT_INPUT_SWITCHING.format( 

2302 name=display_label_for_ref(cfg.chat_model) 

2303 ) 

2304 elif self.reloading_placement: 

2305 inp.placeholder = msg.CHAT_INPUT_RELOADING 

2306 else: 

2307 inp.placeholder = msg.CHAT_INPUT_PLACEHOLDER_DEFAULT 

2308 if not busy and self._insert_mode: 

2309 self.set_focus(inp) 

2310 

2311 def watch_swapping_model(self, swapping: bool) -> None: 

2312 self._apply_input_busy_state() 

2313 

2314 def watch_reloading_placement(self, reloading: bool) -> None: 

2315 self._apply_input_busy_state() 

2316 

2317 def on_fleet_body_placement_reloading(self, event: FleetBody.PlacementReloading) -> None: 

2318 """Hold chat submissions while the Fleet drawer reloads the fleet.""" 

2319 self.reloading_placement = event.active 

2320 

2321 @work(thread=True, name=_MODEL_SWAP_WORKER, exit_on_error=False) 

2322 def _reload_chat_model_worker(self) -> None: 

2323 """Reload the chat role and warm the new model before unblocking the input. 

2324 

2325 ``reload_role(wait=True)`` re-plans and restarts the fleet for the new chat 

2326 model (retrieval is untouched) and returns once the proxy is back up. The 

2327 model is then warmed here rather than deferred to the user's next prompt: 

2328 ``request_engine_warm`` drives the load and populates the provider warm 

2329 tracker, which the task-bar footer renders (spinner, model, phase), and 

2330 ``wait_chat_ready`` holds the input disabled until the model actually 

2331 serves -- so the switch never hands back a live input in front of a model 

2332 that has not loaded. The provider serializes overlapping reloads, so a 

2333 rapid second swap coalesces onto the latest cfg. 

2334 """ 

2335 from lilbee.app.placement import ( 

2336 chat_warm_error, 

2337 request_engine_warm, 

2338 wait_chat_ready, 

2339 ) 

2340 

2341 worker = _get_worker() 

2342 try: 

2343 get_services().reload_role(WorkerRole.CHAT, wait=True) 

2344 request_engine_warm() 

2345 ready = wait_chat_ready(should_abort=lambda: worker.is_cancelled) 

2346 except Exception as exc: # any reload failure becomes a toast, never a crash 

2347 call_from_thread(self, self._on_model_swap_failed, str(exc)) 

2348 return 

2349 if worker.is_cancelled: 

2350 return 

2351 error = None if ready else chat_warm_error() 

2352 if error: 

2353 call_from_thread(self, self._on_model_swap_failed, error) 

2354 else: 

2355 call_from_thread(self, self._on_model_swapped) 

2356 

2357 def _on_model_swapped(self) -> None: 

2358 """Main-thread completion: unblock the input and confirm the new model.""" 

2359 from lilbee.catalog.formatting import display_label_for_ref 

2360 

2361 self.swapping_model = False 

2362 self.app.notify(msg.MODEL_SWAP_DONE.format(name=display_label_for_ref(cfg.chat_model))) 

2363 

2364 def _on_model_swap_failed(self, error: str) -> None: 

2365 """Main-thread failure: unblock the input and surface the error.""" 

2366 self.swapping_model = False 

2367 self.app.notify(msg.MODEL_SWAP_FAILED.format(error=error), severity="error") 

2368 

2369 @on(Markdown.LinkClicked) 

2370 def _open_answer_link(self, event: Markdown.LinkClicked) -> None: 

2371 """Open a link clicked in an answer: ``file:`` citations open in the OS 

2372 default app for the file type; web links open in the browser.""" 

2373 event.stop() 

2374 if event.href.startswith("file://"): 

2375 open_local_file(event.href) 

2376 else: 

2377 self.app.open_url(event.href) 

2378 

2379 async def action_toggle_markdown(self) -> None: 

2380 """Toggle between Markdown and plain-text rendering for chat responses.""" 

2381 cfg.markdown_rendering = not cfg.markdown_rendering 

2382 use_md = cfg.markdown_rendering 

2383 chat_log = self._chat_log 

2384 for widget in chat_log.query(AssistantMessage): 

2385 await widget.rebuild_content_widget(use_md) 

2386 label = "Markdown" if use_md else "Plain text" 

2387 self.notify(msg.CHAT_RENDERING.format(label=label)) 

2388 

2389 def _run_sync(self, *, force_rebuild: bool = False, prune_ignored: bool = False) -> None: 

2390 """Enqueue a document sync (or full rebuild) in the task bar.""" 

2391 if self._sync_active: 

2392 self.notify(msg.SYNC_ALREADY_ACTIVE, severity="warning") 

2393 return 

2394 from lilbee.cli.tui.task_queue import TaskType 

2395 

2396 self._sync_active = True 

2397 # Clear the pending hint so the bar shows live sync progress 

2398 # instead of the stale "N docs to sync" line. 

2399 self._task_bar.clear_pending_sync() 

2400 

2401 def _target(reporter: ProgressReporter) -> None: 

2402 try: 

2403 self._do_sync(reporter, force_rebuild=force_rebuild, prune_ignored=prune_ignored) 

2404 finally: 

2405 self._sync_active = False 

2406 # Re-detect after every sync attempt: success drives the 

2407 # count to 0, failure or cancel leaves the still-pending 

2408 # files counted so the hint reappears. 

2409 self._task_bar.start_detect_pending() 

2410 

2411 label = msg.TASK_NAME_REBUILD if force_rebuild else msg.TASK_NAME_SYNC 

2412 self._task_bar.start_task(label, TaskType.SYNC, _target, indeterminate=True) 

2413 

2414 def _do_sync( 

2415 self, 

2416 reporter: ProgressReporter, 

2417 *, 

2418 force_rebuild: bool = False, 

2419 prune_ignored: bool = False, 

2420 ) -> None: 

2421 """Sync body. Runs on worker thread.""" 

2422 from lilbee.data.ingest import sync 

2423 

2424 reporter.update(0, msg.SYNC_STATUS_SYNCING, indeterminate=True) 

2425 on_progress = build_sync_progress_callback(reporter) 

2426 try: 

2427 result = asyncio_loop.run( 

2428 sync( 

2429 quiet=True, 

2430 on_progress=on_progress, 

2431 force_rebuild=force_rebuild, 

2432 prune_ignored=prune_ignored, 

2433 ) 

2434 ) 

2435 except asyncio.CancelledError as exc: 

2436 raise RuntimeError(msg.SYNC_CANCELLED_RESUME) from exc 

2437 if prune_ignored: 

2438 call_from_thread(self, self.notify, msg.prune_ignored_message(len(result.removed))) 

2439 if result.failed: 

2440 raise RuntimeError(msg.SYNC_FAILED_FILES.format(files=", ".join(result.failed))) 

2441 if result.skipped: 

2442 call_from_thread( 

2443 self, 

2444 self.notify, 

2445 msg.sync_skipped_message(result, tui_log_path()), 

2446 severity="warning", 

2447 ) 

2448 if result.held_out: 

2449 call_from_thread( 

2450 self, 

2451 self.notify, 

2452 msg.SYNC_HELD_OUT.format(count=len(result.held_out)), 

2453 severity="warning", 

2454 ) 

2455 

2456 def action_focus_commands(self) -> None: 

2457 """Focus chat input and pre-fill with '/' for command entry.""" 

2458 # Route through the helper so can_focus is re-enabled when this 

2459 # action fires from NORMAL mode; bare ``inp.focus()`` would 

2460 # silently no-op while the input is intentionally unfocusable. 

2461 self._enter_insert_mode() 

2462 inp = self._chat_input 

2463 if not inp.value.startswith("/"): 

2464 inp.value = "/" 

2465 inp.action_end() 

2466 

2467 def action_toggle_chat_mode(self) -> None: 

2468 """F3: flip between Search and Chat mode.""" 

2469 try: 

2470 toggle = self.query_one(ChatModeToggle) 

2471 except NoMatches: 

2472 return 

2473 if not toggle.toggle(): 

2474 return 

2475 label = ( 

2476 msg.CHAT_MODE_SEARCH_LABEL 

2477 if cfg.chat_mode == ChatMode.SEARCH.value 

2478 else msg.CHAT_MODE_CHAT_LABEL 

2479 ) 

2480 self.notify(msg.CHAT_MODE_SET.format(label=label)) 

2481 

2482 def action_cycle_scope(self) -> None: 

2483 """``s``: cycle the scope chip when it is currently visible.""" 

2484 from lilbee.cli.tui.widgets.scope_chip import ScopeChip 

2485 

2486 try: 

2487 chip = self.query_one("#scope-chip", ScopeChip) 

2488 except NoMatches: 

2489 return 

2490 if chip.has_class("-hidden"): 

2491 return 

2492 chip.cycle_scope() 

2493 

2494 def action_complete(self) -> None: 

2495 """Tab: fill the shared prefix, then cycle matches (readline / vim style). 

2496 

2497 - Insert mode + chat input focused + dropdown closed but matches 

2498 exist: open it, fill the longest common prefix, else preview the 

2499 first match. 

2500 - Insert mode + chat input focused + dropdown open: fill any further 

2501 shared prefix, otherwise preview the next match. 

2502 - Insert mode + chat input focused + no matches: insert ``\\t`` so 

2503 users can type tab characters directly. 

2504 - Normal mode or focus elsewhere: advance through the focus 

2505 chain so Tab still walks every focusable widget. 

2506 """ 

2507 inp = self._chat_input 

2508 if not self._insert_mode or not inp.has_focus: 

2509 self._tab_into_fleet_or_next() 

2510 return 

2511 overlay = self._completion_overlay 

2512 if not overlay.is_visible and not self._open_completions(): 

2513 inp.insert("\t") 

2514 return 

2515 if self._fill_common_prefix(): 

2516 return 

2517 self._preview_next() 

2518 

2519 def _focus_in_drawer(self) -> bool: 

2520 """True when keyboard focus is inside an open drawer, so Esc / Enter / i / a / o 

2521 reach that drawer's own controls instead of switching the chat mode. 

2522 

2523 Asked of the Drawer base rather than one drawer class: a drawer that had 

2524 to name itself here would otherwise swallow its own Enter until someone 

2525 noticed. 

2526 """ 

2527 return drawer_holding(self.focused) is not None 

2528 

2529 def _focus_in_model_bar(self) -> bool: 

2530 """True when focus is on any model-strip member. 

2531 

2532 Asked of the container rather than of each member class so a member 

2533 added later is covered without a second edit here. 

2534 """ 

2535 focused = self.focused 

2536 return bool(focused and any(isinstance(n, ModelBar) for n in focused.ancestors_with_self)) 

2537 

2538 def _tab_into_fleet_or_next(self) -> None: 

2539 """Jump Tab into the open Fleet drawer's first toggle so the placement 

2540 editor is reachable without tabbing past every widget; once focus is 

2541 inside the drawer, Tab cycles within it as usual.""" 

2542 drawers = self.screen.query(FleetDrawer) 

2543 if not drawers: 

2544 self.screen.focus_next() 

2545 return 

2546 drawer = drawers.first() 

2547 focused = self.screen.focused 

2548 inside = focused is not None and drawer in focused.ancestors_with_self 

2549 toggles = drawer.query(".dev-toggle") 

2550 if not inside and toggles: 

2551 self.set_focus(toggles.first()) 

2552 return 

2553 self.screen.focus_next() 

2554 

2555 def action_complete_next(self) -> None: 

2556 """Ctrl+N: preview the next match, opening the dropdown if it is closed (vim ``<C-n>``).""" 

2557 if not self._chat_input.has_focus: 

2558 # Not a completion here; skip so an open overlay (e.g. the sessions 

2559 # drawer) can bind Ctrl+N instead of this priority binding eating it. 

2560 raise SkipAction() 

2561 if self._completion_overlay.is_visible or self._open_completions(): 

2562 self._preview_next() 

2563 

2564 def action_complete_prev(self) -> None: 

2565 """Ctrl+P: preview the previous match, opening the dropdown if it is closed.""" 

2566 if not self._chat_input.has_focus: 

2567 return 

2568 if self._completion_overlay.is_visible or self._open_completions(): 

2569 self._preview_prev() 

2570 

2571 def _preview_next(self) -> None: 

2572 """Preview the highlighted match if none is previewed yet, else step forward.""" 

2573 overlay = self._completion_overlay 

2574 if self._chat_input.value == self._completion_origin: 

2575 display = overlay.get_current() 

2576 else: 

2577 display = overlay.cycle_next() 

2578 if display is not None: 

2579 self._preview_completion(display) 

2580 

2581 def _preview_prev(self) -> None: 

2582 """Step the highlight backward (wrapping to the last match) and preview it.""" 

2583 display = self._completion_overlay.cycle_prev() 

2584 if display is not None: 

2585 self._preview_completion(display) 

2586 

2587 def _open_completions(self) -> bool: 

2588 """Show the dropdown for the current input and remember it as the origin.""" 

2589 options = get_completions(self._chat_input.value) 

2590 if not options: 

2591 return False 

2592 self._completion_origin = self._chat_input.value 

2593 self._completion_overlay.show_completions(options) 

2594 return True 

2595 

2596 def _completion_value(self, display: str) -> str: 

2597 """Full input text produced by accepting ``display``, keeping the typed prefix. 

2598 

2599 Path completions are basenames, so the directory the user already 

2600 typed (``~/``, ``./``, absolute) is preserved and only the final 

2601 segment is replaced. 

2602 """ 

2603 text = ( 

2604 self._completion_origin 

2605 if self._completion_origin is not None 

2606 else (self._chat_input.value) 

2607 ) 

2608 if " " not in text: 

2609 return display 

2610 cmd, _, partial = text.partition(" ") 

2611 if cmd.lower() in PATH_ARG_COMMANDS: 

2612 head = path_completion_prefix(partial) 

2613 return f"{cmd} {head}{display}" 

2614 return f"{cmd} {display}" 

2615 

2616 def _set_input(self, value: str) -> None: 

2617 """Replace the input value without triggering the live-refresh of the dropdown.""" 

2618 inp = self._chat_input 

2619 if inp.value == value: 

2620 return 

2621 # The setter posts Changed asynchronously; flag one event to ignore so 

2622 # the previewed candidate doesn't re-filter (and collapse) the dropdown. 

2623 # (The value setter already moves the cursor to the end.) 

2624 self._suppress_refresh += 1 

2625 inp.value = value 

2626 

2627 def _preview_completion(self, display: str) -> None: 

2628 """Write the highlighted candidate into the input, leaving the dropdown open.""" 

2629 self._set_input(self._completion_value(display)) 

2630 

2631 def _fill_common_prefix(self) -> bool: 

2632 """Extend the input to the longest prefix shared by all matches; True if it grew.""" 

2633 overlay = self._completion_overlay 

2634 values = [self._completion_value(d) for d in overlay.options] 

2635 shared = longest_common_prefix(values) 

2636 if len(shared) <= len(self._chat_input.value): 

2637 return False 

2638 self._set_input(shared) 

2639 # Re-filter for the newly completed prefix (descends into a directory, 

2640 # narrows the model list, etc.). 

2641 self._refresh_completion_overlay() 

2642 return True 

2643 

2644 def action_history_prev(self) -> None: 

2645 """Up arrow: cycle the dropdown if visible, else recall previous history entry.""" 

2646 if not self._insert_mode: 

2647 raise SkipAction() 

2648 inp = self._chat_input 

2649 if not inp.has_focus: 

2650 raise SkipAction() 

2651 # When the completion dropdown is up, Up navigates the dropdown 

2652 # (vim/Emacs-style) rather than recalling history. 

2653 overlay = self._completion_overlay 

2654 if overlay.is_visible: 

2655 self._preview_prev() 

2656 return 

2657 if not self._input_history: 

2658 raise SkipAction() 

2659 if self._history_index == -1: 

2660 self._history_index = len(self._input_history) - 1 

2661 elif self._history_index > 0: 

2662 self._history_index -= 1 

2663 else: 

2664 return 

2665 inp.value = self._input_history[self._history_index] 

2666 inp.action_end() 

2667 

2668 def action_history_next(self) -> None: 

2669 """Down arrow: cycle the dropdown if visible, else recall next history entry.""" 

2670 if not self._insert_mode: 

2671 raise SkipAction() 

2672 inp = self._chat_input 

2673 if not inp.has_focus: 

2674 raise SkipAction() 

2675 # When the completion dropdown is up, Down navigates the dropdown. 

2676 overlay = self._completion_overlay 

2677 if overlay.is_visible: 

2678 self._preview_next() 

2679 return 

2680 if self._history_index == -1: 

2681 raise SkipAction() 

2682 if self._history_index < len(self._input_history) - 1: 

2683 self._history_index += 1 

2684 inp.value = self._input_history[self._history_index] 

2685 inp.action_end() 

2686 else: 

2687 self._history_index = -1 

2688 inp.value = "" 

2689 

2690 @on(ChatInput.Changed, "#chat-input") 

2691 def _on_chat_input_changed(self, event: ChatInput.Changed) -> None: 

2692 """Refresh arg-hint and auto-show or hide the completion dropdown.""" 

2693 if self._suppress_refresh > 0: 

2694 # A programmatic edit (preview / accept / revert) is managing the 

2695 # overlay itself; consume one Changed and skip the live refresh. 

2696 self._suppress_refresh -= 1 

2697 self._refresh_arg_hint() 

2698 return 

2699 self._refresh_completion_overlay() 

2700 self._refresh_arg_hint() 

2701 

2702 def _refresh_completion_overlay(self) -> None: 

2703 """Live-filter the dropdown against the current input, in command and arg modes alike.""" 

2704 overlay = self._completion_overlay 

2705 text = self._chat_input.value 

2706 options = get_completions(text) 

2707 if options: 

2708 self._completion_origin = text 

2709 overlay.show_completions(options) 

2710 elif overlay.is_visible: 

2711 overlay.hide() 

2712 self._completion_origin = None 

2713 

2714 def _refresh_arg_hint(self) -> None: 

2715 """Push the current input value into the ArgHintLine.""" 

2716 self._arg_hint.update_for_input(self._chat_input.value) 

2717 

2718 def refresh_model_bar(self) -> None: 

2719 """Re-scan installed models and refresh the model bar. 

2720 

2721 Show can arrive before the prompt area's descendants have mounted, and 

2722 on a slow runner it does. A bar that is not in the DOM yet scans on its 

2723 own mount, so a missing bar means the scan is already coming, not that 

2724 it was skipped -- the re-scan here is what re-entering the screen needs. 

2725 Querying regardless raised NoMatches out of the show handler, which 

2726 Textual re-raises as an app crash. 

2727 """ 

2728 for bar in self.query("#model-bar").results(ModelBar): 

2729 bar.refresh_models() 

2730 

2731 def action_vim_scroll_down(self) -> None: 

2732 """Vim j: scroll down in normal mode.""" 

2733 if self._insert_mode: 

2734 raise SkipAction() 

2735 self._chat_log.scroll_down() 

2736 

2737 def action_vim_scroll_up(self) -> None: 

2738 """Vim k: scroll up in normal mode.""" 

2739 if self._insert_mode: 

2740 raise SkipAction() 

2741 self._chat_log.scroll_up() 

2742 

2743 def action_vim_scroll_home(self) -> None: 

2744 """Vim g: scroll to top in normal mode.""" 

2745 if self._insert_mode: 

2746 raise SkipAction() 

2747 self._chat_log.scroll_home() 

2748 

2749 def action_vim_scroll_end(self) -> None: 

2750 """Vim G: scroll to bottom in normal mode.""" 

2751 if self._insert_mode: 

2752 raise SkipAction() 

2753 self._chat_log.scroll_end() 

2754 

2755 def action_half_page_down(self) -> None: 

2756 """Ctrl-D: half-page down (vim style).""" 

2757 log_widget = self._chat_log 

2758 half = max(1, log_widget.size.height // 2) 

2759 log_widget.scroll_relative(y=half) 

2760 

2761 def action_half_page_up(self) -> None: 

2762 """Ctrl-U: half-page up (vim style).""" 

2763 log_widget = self._chat_log 

2764 half = max(1, log_widget.size.height // 2) 

2765 log_widget.scroll_relative(y=-half)