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

1056 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-06-28 01:01 +0000

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

2 

3from __future__ import annotations 

4 

5import asyncio 

6import contextlib 

7import logging 

8import os 

9import shlex 

10import threading 

11import time 

12from collections.abc import Callable 

13from pathlib import Path 

14from typing import TYPE_CHECKING, Any, ClassVar 

15 

16from textual import events, getters, on, work 

17from textual.actions import SkipAction 

18from textual.app import ComposeResult 

19from textual.binding import Binding, BindingType 

20from textual.containers import Vertical, VerticalScroll 

21from textual.content import Content 

22from textual.css.query import NoMatches 

23from textual.dom import DOMNode 

24from textual.reactive import reactive 

25from textual.screen import Screen 

26from textual.widgets import Footer, Select, Static 

27 

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

29# since it's used in multiple methods. 

30from textual.worker import get_current_worker as _get_worker 

31 

32from lilbee.app.services import get_services, reset_services, reset_store 

33from lilbee.app.settings_map import SETTINGS_MAP 

34from lilbee.app.themes import DARK_THEMES 

35from lilbee.app.version import get_version 

36from lilbee.cli.tui import messages as msg 

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

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

39 build_add_progress_callback, 

40 build_sync_progress_callback, 

41 close_stream, 

42 remember_from_input, 

43 remove_copied_files, 

44) 

45from lilbee.cli.tui.thread_safe import call_from_thread 

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

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

48 CompletionOverlay, 

49 get_completions, 

50 longest_common_prefix, 

51 path_completion_prefix, 

52) 

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

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

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

56from lilbee.cli.tui.widgets.model_bar import ChatModeToggle, ModelBar, ModelPickerButton 

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

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

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

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

61from lilbee.core.config import cfg 

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

63from lilbee.crawler import crawler_available, is_url, require_valid_crawl_url 

64from lilbee.data.store import ChunkType, EmbeddingModelMismatchError, scope_to_chunk_type 

65from lilbee.providers.model_ref import parse_model_ref 

66from lilbee.retrieval.embedder import is_model_available 

67from lilbee.retrieval.query import ChatMessage 

68from lilbee.retrieval.query.history_window import windowed_history 

69from lilbee.runtime import asyncio_loop 

70from lilbee.runtime.progress import ( 

71 EventType, 

72 ProgressEvent, 

73) 

74 

75if TYPE_CHECKING: 

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

77log = logging.getLogger(__name__) 

78 

79_HISTORY_TOKEN_BUDGET_FRACTION = 0.5 

80"""Fraction of ``cfg.chat_n_ctx_target`` reserved for prior conversation history. 

81 

82The other half of the working context is for the system prompt, the current 

83turn's RAG context (~8 chunks), the user question, and reasoning headroom. 

84The windower drops oldest user/assistant pairs once history exceeds this 

85fraction so the assembled prompt never approaches ``n_ctx`` and llama-cpp 

86never errors with "Requested tokens exceed context window." 

87""" 

88 

89# Auto-follow tolerance, in lines: the user counts as "at the bottom" within 

90# this many lines of it, so a tiny stray scroll doesn't disable auto-follow and 

91# scrolling back near the bottom re-engages it. 

92_AUTO_SCROLL_TAIL_LINES = 5 

93 

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

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

96# message queue and makes key events visibly lag. 

97_STREAM_FLUSH_INTERVAL = 0.05 

98 

99# Auto-scroll throttle. ~6 fps so heavy token streams don't peg the renderer. 

100_STREAM_SCROLL_INTERVAL = 0.15 

101 

102# ``/crawl`` command flags. 

103_CRAWL_FLAG_DEPTH = "--depth" 

104_CRAWL_FLAG_MAX_PAGES = "--max-pages" 

105_CRAWL_FLAG_INCLUDE_SUBDOMAINS = "--include-subdomains" 

106_CRAWL_FLAG_RENDER = "--render" 

107 

108 

109class ChatWelcome(Static): 

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

111 

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

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

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

115 hint = Content.styled(msg.CHAT_WELCOME_HINT, "$text-muted") 

116 body = Content.assemble(title, "\n", tagline, "\n\n", hint) 

117 super().__init__(body, id=id) 

118 

119 

120class PromptArea(Vertical): 

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

122 

123 pass 

124 

125 

126class ChatScreen(Screen[None]): 

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

128 

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

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

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

132 app: LilbeeApp # type: ignore[assignment] 

133 

134 CSS_PATH = "chat.tcss" 

135 AUTO_FOCUS = "#chat-input" 

136 

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

138 

139 HELP = ( 

140 "# Chat\n\n" 

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

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

143 "**i**/**a**/**o** to return to insert mode." 

144 ) 

145 

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

147 

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

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

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

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

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

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

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

155 

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

157 # `/` opens the slash-command line (Tab completes it -- the 

158 # adjacent `Tab Complete` hint spells that out). The label says 

159 # "Slash commands" rather than the bare "Commands" so the footer 

160 # tells the user what `/` actually does. 

161 Binding("slash", "focus_commands", "Slash commands", show=True), 

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

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

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

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

166 # LilbeeApp.action_command_palette overriding to call 

167 # ChatScreen.action_complete_prev. Action is exposed for direct 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

190 # check_action below hides + disables it outside that exact 

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

192 # nothing is streaming. 

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

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

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

196 # F2 opens the searchable list of every slash command 

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

198 # Labeled "All commands" so it reads distinctly from `/ Slash commands`. 

199 Binding("f2", "show_command_catalog", "All commands", show=True, priority=True), 

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

201 Binding("f5", "open_setup", "Setup", show=False), 

202 ] 

203 

204 def __init__(self) -> None: 

205 super().__init__() 

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

207 self._history_lock = threading.Lock() 

208 self._insert_mode: bool = True 

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

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

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

212 self._suppress_refresh = 0 

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

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

215 self._completion_origin: str | None = None 

216 self._sync_active: bool = False 

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

218 self._history_index: int = -1 

219 self._tail_scroll_y: float = 0.0 

220 self._auto_follow: bool = True 

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

222 

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

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

225 

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

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

228 """ 

229 from lilbee.cli.tui.command_registry import COMMANDS 

230 

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

232 for cmd in COMMANDS: 

233 method = getattr(self, cmd.handler) 

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

235 handlers[name] = method 

236 return handlers 

237 

238 @property 

239 def _task_bar(self) -> TaskBarController: 

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

241 return self.app.task_bar 

242 

243 def compose(self) -> ComposeResult: 

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

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

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

247 

248 with TopBars(): 

249 yield ViewTabs() 

250 yield VerticalScroll( 

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

252 id="chat-log", 

253 ) 

254 with BottomBars(): 

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

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

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

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

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

260 yield ChatInput( 

261 placeholder=msg.CHAT_INPUT_PLACEHOLDER_DEFAULT, 

262 id="chat-input", 

263 ) 

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

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

266 yield TaskBar() 

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

268 yield Footer() 

269 

270 def on_mount(self) -> None: 

271 self._update_input_style() 

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

273 self._setup_check_worker() 

274 

275 @work(thread=True, name="chat_setup_check", exit_on_error=False) 

276 def _setup_check_worker(self) -> None: 

277 """Run ``_needs_setup`` off the UI thread; push the wizard if needed.""" 

278 if not self._needs_setup(): 

279 return 

280 call_from_thread(self, self._push_setup_wizard) 

281 

282 def _push_setup_wizard(self) -> None: 

283 """Push the SetupWizard if the screen is still mounted.""" 

284 if not self.is_mounted: 

285 return 

286 from lilbee.cli.tui.screens.setup import SetupWizard 

287 

288 self.app.push_screen(SetupWizard(), self._on_setup_complete) 

289 

290 def on_show(self) -> None: 

291 """Called when screen becomes visible.""" 

292 from lilbee.runtime.splash import dismiss 

293 

294 dismiss() 

295 self.refresh_model_bar() 

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

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

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

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

300 # so global bindings keep firing). 

301 with contextlib.suppress(Exception): 

302 if self._insert_mode: 

303 self._enter_insert_mode() 

304 else: 

305 self._chat_log.focus() 

306 

307 def _needs_setup(self) -> bool: 

308 """True when the setup wizard should run: fresh data dir or unresolved models. 

309 

310 Remote-prefixed refs (ollama/lm_studio/API) are validated against 

311 current state instead of probed on disk: an ``ollama/`` ref whose 

312 litellm extra is missing or whose server is down is unusable and 

313 must route the user to setup, not be assumed live. 

314 """ 

315 if not cfg.lancedb_dir.is_dir(): 

316 log.debug("_needs_setup: lancedb_dir missing (%s)", cfg.lancedb_dir) 

317 return True 

318 from lilbee.modelhub.model_manager import ValidationResult, validate_persisted_model 

319 from lilbee.providers.base import ProviderError 

320 from lilbee.providers.llama_cpp.provider import resolve_model_path 

321 

322 for label, model in (("chat", cfg.chat_model), ("embedding", cfg.embedding_model)): 

323 if parse_model_ref(model).is_remote: 

324 if validate_persisted_model(model) != ValidationResult.OK: 

325 log.debug("_needs_setup: remote %s model %r not usable", label, model) 

326 return True 

327 continue 

328 try: 

329 resolve_model_path(model) 

330 except (ProviderError, KeyError, ValueError) as exc: 

331 log.debug("_needs_setup: %s model %r unresolved: %s", label, model, exc) 

332 return True 

333 return False 

334 

335 def _embedding_ready(self) -> bool: 

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

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

338 

339 def _on_setup_complete(self, result: str | None) -> None: 

340 """Called when wizard completes or is skipped.""" 

341 # Re-detect after setup so a freshly-set-up vault gets the hint. 

342 self.app.task_bar.start_detect_pending() 

343 self.refresh_model_bar() 

344 

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

346 key, _value = payload 

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

348 self.refresh_model_bar() 

349 

350 def action_open_setup(self) -> None: 

351 """Open the setup wizard.""" 

352 self._cmd_setup("") 

353 

354 def _enter_insert_mode(self) -> None: 

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

356 self._insert_mode = True 

357 self._chat_input.can_focus = True 

358 self._chat_input.focus() 

359 self._update_input_style() 

360 

361 def _update_input_style(self) -> None: 

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

363 inp = self._chat_input 

364 if self._insert_mode: 

365 inp.remove_class("normal-mode") 

366 else: 

367 inp.add_class("normal-mode") 

368 self._update_mode_indicator() 

369 

370 def _update_mode_indicator(self) -> None: 

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

372 with contextlib.suppress(NoMatches): 

373 bar = self.query_one(ViewTabs) 

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

375 

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

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

378 from textual.events import Key 

379 

380 if not isinstance(event, Key): 

381 return 

382 inp = self._chat_input 

383 if self._insert_mode: 

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

385 inp.focus() 

386 inp.insert(event.character) 

387 event.prevent_default() 

388 event.stop() 

389 return 

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

391 # Let a focused Select / picker button handle Enter / i / a / o itself. 

392 if isinstance(self.focused, (Select, ModelPickerButton)): 

393 return 

394 self._enter_insert_mode() 

395 event.prevent_default() 

396 event.stop() 

397 return 

398 

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

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

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

402 

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

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

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

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

407 the screen mode here. 

408 """ 

409 if not self._insert_mode: 

410 self._enter_insert_mode() 

411 

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

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

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

415 

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

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

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

419 """ 

420 if not self._insert_mode: 

421 self._enter_insert_mode() 

422 event.stop() 

423 

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

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

426 

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

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

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

430 """ 

431 if not self._insert_mode: 

432 return 

433 if event.widget is None: 

434 return 

435 chat_input = self._chat_input 

436 node: DOMNode | None = event.widget 

437 while node is not None: 

438 if node is chat_input: 

439 return 

440 node = node.parent 

441 self.action_enter_normal_mode() 

442 

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

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

445 if not self._insert_mode: 

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

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

448 self._enter_insert_mode() 

449 return 

450 if self.streaming: 

451 # Only one chat message may be in flight at a time. Surface a 

452 # toast so the user knows the prompt was rejected (rather 

453 # than silently dropped) and ask them to cancel first if 

454 # they want to redirect the model. 

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

456 return 

457 # Enter when the completion dropdown is showing a different 

458 # selection than the input itself: accept the highlight first 

459 # (matches Tab's cycle-and-insert behavior) instead of submitting 

460 # whatever bare prefix the user typed. 

461 if self._accept_overlay_selection_on_enter(): 

462 return 

463 text = event.value.strip() 

464 if not text: 

465 return 

466 if not text.startswith("/"): 

467 pending = self._pending_required_model_download() 

468 if pending is not None: 

469 # Keep the typed prompt in the input so the user can submit 

470 # it again once the download finishes, instead of forcing 

471 # them to retype. 

472 self.notify( 

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

474 severity="warning", 

475 timeout=5, 

476 ) 

477 return 

478 event.chat_input.value = "" 

479 self._input_history.append(text) 

480 self._history_index = -1 

481 

482 if text.startswith("/"): 

483 self._handle_slash(text) 

484 return 

485 self._send_message(text) 

486 

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

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

489 

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

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

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

493 """ 

494 task_bar = self.app.task_bar 

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

496 label = task_bar.downloading_label_for(ref) 

497 if label is not None: 

498 return label 

499 return None 

500 

501 def _accept_overlay_selection_on_enter(self) -> bool: 

502 """Accept the highlighted completion on Enter; True if Enter was consumed. 

503 

504 If the input already holds the highlighted candidate (the user cycled 

505 to it), Enter falls through to submit. Otherwise the candidate is 

506 filled in and Enter is consumed so a second Enter submits. 

507 """ 

508 overlay = self._completion_overlay 

509 if not overlay.is_visible: 

510 return False 

511 display = overlay.get_current() 

512 if display is None: 

513 overlay.hide() 

514 self._completion_origin = None 

515 return False 

516 target = self._completion_value(display) 

517 consumed = self._chat_input.value != target 

518 if consumed: 

519 self._set_input(target) 

520 overlay.hide() 

521 self._completion_origin = None 

522 return consumed 

523 

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

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

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

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

528 handler = self._command_handlers.get(cmd) 

529 if handler is not None: 

530 handler(args) 

531 else: 

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

533 

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

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

536 self.streaming = value 

537 

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

539 if streaming: 

540 self._enter_streaming_state() 

541 else: 

542 self._exit_streaming_state() 

543 

544 def _enter_streaming_state(self) -> None: 

545 self.add_class("streaming") 

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

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

548 self.refresh_bindings() 

549 

550 def _exit_streaming_state(self) -> None: 

551 self.remove_class("streaming") 

552 self.refresh_bindings() 

553 

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

555 if not args: 

556 return 

557 if self._sync_active: 

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

559 return 

560 if is_url(args): 

561 self._cmd_crawl(args) 

562 return 

563 # Platform-aware shell parsing: POSIX rules treat backslashes as 

564 # escapes, so a Windows path like C:\Users\foo gets mangled to 

565 # C:Usersfoo. shlex(posix=False) keeps backslashes literal but 

566 # leaves surrounding quotes attached to tokens, so trim those 

567 # before constructing Path objects. 

568 try: 

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

570 except ValueError as exc: 

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

572 return 

573 if os.name == "nt": 

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

575 paths = [Path(token).expanduser() for token in tokens] 

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

577 if missing: 

578 self.notify( 

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

580 severity="error", 

581 ) 

582 return 

583 # Directory adds are whole-tree copies handled by copy_files' 

584 # recursion; a same-named subdir in documents_dir is not a clean 

585 # "duplicate file" signal, so skip the prompt there and let 

586 # copy_files emit its per-file skipped notices. 

587 duplicates = [p for p in paths if p.is_file() and (cfg.documents_dir / p.name).exists()] 

588 if duplicates: 

589 self._prompt_overwrite(paths, duplicates) 

590 return 

591 self._submit_add(paths, force=False) 

592 

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

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

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

596 

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

598 

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

600 if not confirmed: 

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

602 return 

603 self._submit_add(paths, force=True) 

604 

605 self.app.push_screen( 

606 ConfirmDialog( 

607 msg.CMD_ADD_DUPLICATE_TITLE, 

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

609 ), 

610 _on_confirm, 

611 ) 

612 

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

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

615 from lilbee.cli.tui.task_queue import TaskType 

616 

617 self._sync_active = True 

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

619 

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

621 try: 

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

623 finally: 

624 self._sync_active = False 

625 

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

627 

628 def _do_add( 

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

630 ) -> None: 

631 """Copy files and run sync. Called on worker thread with a reporter.""" 

632 from lilbee.app.ingest import copy_files 

633 from lilbee.data.ingest import sync 

634 

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

636 reporter.update(0, f"Copying {label}...", indeterminate=True) 

637 copy_result = copy_files(paths, force=force) 

638 copied = copy_result.copied 

639 for name in copy_result.skipped: 

640 call_from_thread(self, self.notify, f"{name} already exists (use --force to overwrite)") 

641 reporter.update(0, f"Copied {len(copied)} file(s), syncing...", indeterminate=True) 

642 

643 try: 

644 sync_result = asyncio_loop.run( 

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

646 ) 

647 except BaseException: 

648 # On cancel or any failure, remove the files we copied into 

649 # documents/ so the next sync doesn't silently re-ingest the 

650 # file the user just cancelled. Only files copied by 

651 # this /add invocation are removed; pre-existing files the user 

652 # put in documents/ themselves are never touched. 

653 remove_copied_files(copied) 

654 raise 

655 if sync_result.failed: 

656 remove_copied_files(copied) 

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

658 if sync_result.skipped: 

659 remove_copied_files(copied) 

660 raise RuntimeError(msg.sync_skipped_message(", ".join(sync_result.skipped))) 

661 call_from_thread(self, self.notify, msg.CMD_ADD_SUCCESS.format(count=len(copied))) 

662 

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

664 for worker in self.workers: 

665 worker.cancel() 

666 self.notify(msg.CMD_CANCEL) 

667 

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

669 for worker in self.workers: 

670 worker.cancel() 

671 self.streaming = False 

672 chat_log = self._chat_log 

673 chat_log.remove_children() 

674 with self._history_lock: 

675 self._history.clear() 

676 self.notify(msg.CMD_CLEAR) 

677 

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

679 if not crawler_available(): 

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

681 return 

682 if not args: 

683 self._open_crawl_dialog() 

684 return 

685 parts = args.split() 

686 url = parts[0] 

687 if not is_url(url): 

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

689 try: 

690 require_valid_crawl_url(url) 

691 except ValueError as exc: 

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

693 return 

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

695 self._start_crawl( 

696 url, 

697 depth, 

698 max_pages, 

699 include_subdomains=include_subdomains, 

700 render_mode=render_mode, 

701 ) 

702 

703 def _open_crawl_dialog(self) -> None: 

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

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

706 

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

708 if result is not None: 

709 self._start_crawl( 

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

711 ) 

712 

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

714 

715 def _start_crawl( 

716 self, 

717 url: str, 

718 depth: int | None, 

719 max_pages: int | None, 

720 *, 

721 include_subdomains: bool = False, 

722 render_mode: CrawlRenderMode | None = None, 

723 ) -> None: 

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

725 

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

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

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

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

730 sticks for the next crawl. 

731 """ 

732 from lilbee.cli.tui.task_queue import TaskType 

733 

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

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

736 self._persist_crawl_render_mode(render_mode) 

737 

738 def _kick_off_crawl() -> None: 

739 self._task_bar.start_task( 

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

741 TaskType.CRAWL, 

742 lambda reporter: self._do_crawl( 

743 url, 

744 depth, 

745 max_pages, 

746 reporter, 

747 include_subdomains=include_subdomains, 

748 render_mode=mode, 

749 ), 

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

751 ) 

752 

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

754 if mode is CrawlRenderMode.BROWSER: 

755 self._task_bar.ensure_chromium(_kick_off_crawl) 

756 else: 

757 _kick_off_crawl() 

758 

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

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

761 from lilbee.app.settings import apply_settings_update 

762 

763 try: 

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

765 except (ValueError, OSError) as exc: 

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

767 

768 @staticmethod 

769 def _parse_crawl_flags( 

770 tokens: list[str], 

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

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

773 

774 Numeric flags return None when absent so the caller inherits 

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

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

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

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

779 """ 

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

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

782 include_subdomains = False 

783 render_mode: CrawlRenderMode | None = None 

784 i = 0 

785 while i < len(tokens): 

786 if tokens[i] == _CRAWL_FLAG_INCLUDE_SUBDOMAINS: 

787 include_subdomains = True 

788 i += 1 

789 continue 

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

791 with contextlib.suppress(ValueError): 

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

793 i += 2 

794 continue 

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

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

797 with contextlib.suppress(ValueError): 

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

799 i += 2 

800 else: 

801 i += 1 

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

803 

804 def _do_crawl( 

805 self, 

806 url: str, 

807 depth: int | None, 

808 max_pages: int | None, 

809 reporter: ProgressReporter, 

810 *, 

811 include_subdomains: bool = False, 

812 render_mode: CrawlRenderMode | None = None, 

813 ) -> None: 

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

815 from lilbee.crawler import crawl_and_save 

816 from lilbee.runtime.progress import CrawlPageEvent, SetupProgressEvent 

817 

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

819 

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

821 if event_type == EventType.SETUP_START: 

822 reporter.update(0, msg.SETUP_CHROMIUM_NAME) 

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

824 if data.total_bytes: 

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

826 detail = msg.SETUP_CHROMIUM_DETAIL.format( 

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

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

829 ) 

830 else: 

831 pct = 0 

832 detail = msg.SETUP_CHROMIUM_DETAIL_UNKNOWN.format( 

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

834 ) 

835 reporter.update(pct, detail) 

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

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

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

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

840 # soon as the total is known. 

841 if data.total > 0: 

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

843 reporter.update( 

844 pct, 

845 msg.CMD_CRAWL_PAGE.format( 

846 current=data.current, total=data.total, url=data.url 

847 ), 

848 indeterminate=False, 

849 ) 

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

851 reporter.update( 

852 0, 

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

854 indeterminate=True, 

855 ) 

856 

857 paths = asyncio_loop.run( 

858 crawl_and_save( 

859 url, 

860 depth=depth, 

861 max_pages=max_pages, 

862 on_progress=on_progress, 

863 quiet=True, 

864 include_subdomains=include_subdomains, 

865 render_mode=render_mode, 

866 ) 

867 ) 

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

869 

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

871 self.app.switch_view("Catalog") 

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

873 

874 self.app.push_screen(CatalogScreen()) 

875 

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

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

878 self._cmd_delete_worker(args.strip()) 

879 

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

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

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

883 try: 

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

885 except Exception: 

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

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

888 return 

889 

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

891 if not known: 

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

893 return 

894 

895 if not name: 

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

897 call_from_thread(self, self.notify, usage) 

898 return 

899 

900 if name not in known: 

901 call_from_thread( 

902 self, 

903 self.notify, 

904 msg.CMD_DELETE_NOT_FOUND.format(name=name), 

905 severity="error", 

906 ) 

907 return 

908 

909 get_services().store.remove_documents([name]) 

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

911 

912 invalidate_document_cache() 

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

914 

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

916 """Run /export in a worker so the chat screen stays interactive.""" 

917 path = args.strip() 

918 if not path: 

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

920 return 

921 self._cmd_export_worker(path) 

922 

923 @work(thread=True, name="chat_cmd_export", exit_on_error=False) 

924 def _cmd_export_worker(self, raw_path: str) -> None: 

925 """Build and write the dataset off the UI thread; notify back via dispatch.""" 

926 from lilbee.app.dataset import DatasetError, export_to_path 

927 

928 output = Path(raw_path).expanduser() 

929 try: 

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

931 except DatasetError as exc: 

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

933 return 

934 call_from_thread( 

935 self, 

936 self.notify, 

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

938 ) 

939 

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

941 """Run /import in a worker so re-embedding doesn't block the UI.""" 

942 path = args.strip() 

943 if not path: 

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

945 return 

946 self._cmd_import_worker(path) 

947 

948 @work(thread=True, name="chat_cmd_import", exit_on_error=False) 

949 def _cmd_import_worker(self, raw_path: str) -> None: 

950 """Load and re-embed the dataset off the UI thread; notify back via dispatch.""" 

951 from lilbee.app.dataset import DatasetError, import_from_path 

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

953 

954 try: 

955 summary = asyncio_loop.run(import_from_path(Path(raw_path).expanduser(), "")) 

956 except DatasetError as exc: 

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

958 return 

959 invalidate_document_cache() 

960 call_from_thread( 

961 self, 

962 self.notify, 

963 msg.CMD_IMPORT_SUCCESS.format( 

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

965 ), 

966 ) 

967 

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

969 self.action_show_command_catalog() 

970 

971 def action_show_command_catalog(self) -> None: 

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

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

974 

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

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

977 self._enter_insert_mode() 

978 inp = self._chat_input 

979 inp.value = f"{name} " 

980 inp.action_end() 

981 

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

983 if name is None: 

984 return 

985 self.insert_slash_command(name) 

986 

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

988 token = args.strip() 

989 if not token: 

990 import webbrowser 

991 

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

993 self.notify(msg.CHAT_LOGIN_PROMPT) 

994 return 

995 self._run_hf_login(token) 

996 

997 @work(thread=True) 

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

999 try: 

1000 from huggingface_hub import login 

1001 

1002 login(token=token, add_to_git_credential=False) 

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

1004 except Exception as exc: 

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

1006 call_from_thread( 

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

1008 ) 

1009 

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

1011 if args: 

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

1013 self.app.title = f"lilbee -- {cfg.chat_model}" 

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

1015 self.apply_model_change() 

1016 self.refresh_model_bar() 

1017 else: 

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

1019 

1020 self.app.push_screen(CatalogScreen()) 

1021 

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

1023 self.app.exit() 

1024 

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

1026 name = args.strip() 

1027 if not name: 

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

1029 return 

1030 self._run_remove_model(name) 

1031 

1032 @work(thread=True) 

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

1034 mgr = get_services().model_manager 

1035 if not mgr.is_installed(name): 

1036 call_from_thread( 

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

1038 ) 

1039 return 

1040 try: 

1041 removed = mgr.remove(name) 

1042 if removed: 

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

1044 else: 

1045 call_from_thread( 

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

1047 ) 

1048 except Exception: 

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

1050 call_from_thread( 

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

1052 ) 

1053 

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

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

1056 

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

1058 if not confirmed: 

1059 return 

1060 self._run_sync(force_rebuild=True) 

1061 

1062 self.app.push_screen( 

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

1064 _on_confirm, 

1065 ) 

1066 

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

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

1069 

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

1071 if not confirmed: 

1072 return 

1073 from lilbee.app.reset import perform_reset 

1074 

1075 try: 

1076 result = perform_reset() 

1077 except Exception as exc: 

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

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

1080 return 

1081 

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

1083 reset_store() 

1084 

1085 if result.skipped: 

1086 self.notify( 

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

1088 severity="warning", 

1089 ) 

1090 else: 

1091 self.notify(msg.CMD_RESET_SUCCESS) 

1092 

1093 self.app.push_screen( 

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

1095 _on_confirm, 

1096 ) 

1097 

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

1099 if not args: 

1100 return 

1101 parts = args.split(None, 1) 

1102 key = parts[0] 

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

1104 

1105 if key not in SETTINGS_MAP: 

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

1107 return 

1108 

1109 defn = SETTINGS_MAP[key] 

1110 if not defn.writable: 

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

1112 return 

1113 try: 

1114 if defn.type is bool: 

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

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

1117 parsed = None 

1118 else: 

1119 parsed = defn.type(value) 

1120 # Route through set_setting so settings_changed_signal subscribers 

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

1122 # _invalidate_caches now handles llm_provider service reset. 

1123 self.app.set_setting(key, parsed) 

1124 self.notify(msg.CMD_SET_SUCCESS.format(key=key, value=parsed)) 

1125 except (ValueError, TypeError) as exc: 

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

1127 

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

1129 self.app.switch_view("Settings") 

1130 

1131 def _cmd_setup(self, _args: str) -> None: 

1132 from lilbee.cli.tui.screens.setup import SetupWizard 

1133 

1134 self.app.push_screen(SetupWizard(), self._on_setup_complete) 

1135 

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

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

1138 self._cmd_remember_worker(args) 

1139 

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

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

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

1143 outcome = remember_from_input(raw) 

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

1145 

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

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

1148 

1149 self.app.push_screen(MemoriesScreen()) 

1150 

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

1152 self.app.switch_view("Status") 

1153 

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

1155 if args: 

1156 self.app.set_theme(args) 

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

1158 else: 

1159 theme_list = msg.CMD_THEME_LIST.format(names=", ".join(DARK_THEMES)) 

1160 self.notify(theme_list, severity="information") 

1161 

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

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

1164 

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

1166 if not cfg.wiki: 

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

1168 return 

1169 self.app.switch_view("Wiki") 

1170 

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

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

1173 from textual.css.query import NoMatches 

1174 

1175 log = self._chat_log 

1176 with contextlib.suppress(NoMatches): 

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

1178 log.mount(UserMessage(text)) 

1179 

1180 # The assistant bubble owns its own ThinkingHeader animator until 

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

1182 assistant_msg = AssistantMessage() 

1183 log.mount(assistant_msg) 

1184 log.scroll_end(animate=False) 

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

1186 # scrolled up during the previous response. 

1187 self._auto_follow = True 

1188 self._tail_scroll_y = 0.0 

1189 

1190 with self._history_lock: 

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

1192 self.streaming = True 

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

1194 

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

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

1197 

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

1199 otherwise. Defaults to ``None`` when the ScopeChip isn't mounted 

1200 (e.g. test apps that compose the screen without it). 

1201 """ 

1202 from textual.css.query import NoMatches 

1203 

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

1205 

1206 try: 

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

1208 except NoMatches: 

1209 return None 

1210 return scope_to_chunk_type(chip.scope) 

1211 

1212 @work(thread=True) 

1213 def _stream_response( 

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

1215 ) -> None: 

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

1217 self._do_stream_response(question, widget, chunk_type) 

1218 

1219 def _do_stream_response( 

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

1221 ) -> None: 

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

1223 response_parts: list[str] = [] 

1224 sources: list[str] = [] 

1225 stream: Any = None 

1226 try: 

1227 with self._history_lock: 

1228 history_snapshot = self._history[:-1] 

1229 stream = get_services().searcher.ask_stream( 

1230 question, history=history_snapshot, chunk_type=chunk_type 

1231 ) 

1232 self._consume_stream(stream, widget, response_parts) 

1233 except EmbeddingModelMismatchError as exc: 

1234 with contextlib.suppress(Exception): 

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

1236 except Exception as exc: 

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

1238 with contextlib.suppress(Exception): 

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

1240 finally: 

1241 close_stream(stream) 

1242 self._finalize_stream(widget, sources, response_parts) 

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

1244 

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

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

1247 

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

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

1250 """ 

1251 from lilbee.app.memory import auto_extract_enabled 

1252 

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

1254 return 

1255 self._extract_memories_worker(question, answer) 

1256 

1257 def _indexing_active(self) -> bool: 

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

1259 from lilbee.cli.tui.task_queue import TaskType 

1260 

1261 busy = {TaskType.SYNC.value, TaskType.ADD.value} 

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

1263 

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

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

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

1267 from lilbee.app.memory import auto_extract 

1268 

1269 stored = auto_extract(question, answer) 

1270 if stored: 

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

1272 

1273 def _on_embedding_mismatch( 

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

1275 ) -> None: 

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

1277 if not exc.dims_match: 

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

1279 return 

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

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

1282 

1283 self.app.push_screen( 

1284 ConfirmDialog( 

1285 msg.EMBED_ADOPT_CONFIRM_TITLE, 

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

1287 ), 

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

1289 ) 

1290 

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

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

1293 if not confirmed: 

1294 self.notify(msg.EMBED_ADOPT_CANCELLED) 

1295 return 

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

1297 self._adopt_and_retry(ref, question) 

1298 

1299 @work(thread=True) 

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

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

1302 self._do_adopt_and_retry(ref, question) 

1303 

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

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

1306 from lilbee.app.models import adopt_embedder 

1307 

1308 try: 

1309 adopt_embedder(ref) 

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

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

1312 call_from_thread( 

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

1314 ) 

1315 return 

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

1317 call_from_thread(self, self._send_message, question) 

1318 

1319 def _consume_stream( 

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

1321 ) -> None: 

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

1323 worker = _get_worker() 

1324 reason_buf: list[str] = [] 

1325 content_buf: list[str] = [] 

1326 timings = [time.monotonic(), 0.0] # [last_flush, last_scroll] 

1327 

1328 def flush() -> None: 

1329 if reason_buf: 

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

1331 reason_buf.clear() 

1332 if content_buf: 

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

1334 content_buf.clear() 

1335 

1336 for token in stream: 

1337 if worker.is_cancelled: 

1338 break 

1339 try: 

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

1341 self._maybe_flush_and_scroll(flush, timings) 

1342 except Exception: 

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

1344 with contextlib.suppress(Exception): 

1345 flush() 

1346 

1347 @staticmethod 

1348 def _buffer_token( 

1349 token: Any, 

1350 reason_buf: list[str], 

1351 content_buf: list[str], 

1352 response_parts: list[str], 

1353 ) -> None: 

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

1355 if token.is_reasoning: 

1356 reason_buf.append(token.content) 

1357 elif token.content: 

1358 response_parts.append(token.content) 

1359 content_buf.append(token.content) 

1360 

1361 def _maybe_flush_and_scroll(self, flush: Callable[[], None], timings: list[float]) -> None: 

1362 """Run *flush* and the auto-scroll on their respective intervals.""" 

1363 now = time.monotonic() 

1364 if now - timings[0] >= _STREAM_FLUSH_INTERVAL: 

1365 flush() 

1366 timings[0] = now 

1367 if now - timings[1] >= _STREAM_SCROLL_INTERVAL: 

1368 call_from_thread(self, self._scroll_to_bottom) 

1369 timings[1] = now 

1370 

1371 def _finalize_stream( 

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

1373 ) -> None: 

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

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

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

1377 call_from_thread(self, self._set_streaming, False) 

1378 full_response = "".join(response_parts) 

1379 if full_response: 

1380 with self._history_lock: 

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

1382 self._trim_history() 

1383 call_from_thread(self, widget.finish, sources) 

1384 call_from_thread(self, self._scroll_to_bottom) 

1385 if ( 

1386 cfg.chat_mode == ChatMode.SEARCH.value 

1387 and self._embedding_ready() 

1388 and full_response 

1389 and "\n\nSources:\n" not in full_response 

1390 ): 

1391 call_from_thread(self, self._notify_no_results) 

1392 

1393 def _notify_no_results(self) -> None: 

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

1395 

1396 def _trim_history(self) -> None: 

1397 """Window history to a token budget. Caller must hold _history_lock. 

1398 

1399 The budget is a fraction of ``cfg.chat_n_ctx_target`` so the 

1400 assembled prompt (system + history + RAG + user) stays under the 

1401 loaded model's ``n_ctx`` regardless of how many turns have run. 

1402 """ 

1403 budget = int(cfg.chat_n_ctx_target * _HISTORY_TOKEN_BUDGET_FRACTION) 

1404 self._history[:] = windowed_history(self._history, max_tokens=budget) 

1405 

1406 def _scroll_to_bottom(self) -> None: 

1407 log_widget = self._chat_log 

1408 # Re-engage auto-follow when the user is at the live bottom; disengage 

1409 # when they scroll up from where the last auto-scroll parked them. The 

1410 # disengage test compares against that parked position, not the live 

1411 # max_scroll_y: content is appended between scroll ticks, so max_scroll_y 

1412 # races ahead of a parked scroll_y, and a live-gap test would read that 

1413 # as a scroll-up and stop auto-follow for the rest of the response. 

1414 if log_widget.scroll_y >= log_widget.max_scroll_y - _AUTO_SCROLL_TAIL_LINES: 

1415 self._auto_follow = True 

1416 elif log_widget.scroll_y < self._tail_scroll_y - _AUTO_SCROLL_TAIL_LINES: 

1417 self._auto_follow = False 

1418 if self._auto_follow: 

1419 log_widget.scroll_end(animate=False) 

1420 self._tail_scroll_y = log_widget.max_scroll_y 

1421 

1422 def action_scroll_up(self) -> None: 

1423 self._chat_log.scroll_page_up() 

1424 

1425 def action_scroll_down(self) -> None: 

1426 self._chat_log.scroll_page_down() 

1427 

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

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

1430 

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

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

1433 """ 

1434 if action == "cancel_stream": 

1435 return self.streaming and self._insert_mode 

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

1437 

1438 def action_enter_normal_mode(self) -> None: 

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

1440 overlay = self._completion_overlay 

1441 if overlay.is_visible: 

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

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

1444 self._completion_origin 

1445 ): 

1446 self._set_input(self._completion_origin) 

1447 self._completion_origin = None 

1448 overlay.hide() 

1449 return 

1450 if isinstance(self.focused, (Select, ModelPickerButton)): 

1451 # Returning from a model picker should put us back in INSERT 

1452 # so the user can type their next prompt; routing through the 

1453 # helper makes sure can_focus is re-enabled. 

1454 self._enter_insert_mode() 

1455 return 

1456 self._insert_mode = False 

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

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

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

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

1461 self._chat_input.can_focus = False 

1462 self._chat_log.focus() 

1463 self._update_input_style() 

1464 

1465 def action_cancel_stream(self) -> None: 

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

1467 if self.streaming: 

1468 self._cancel_inflight_stream() 

1469 

1470 def _cancel_inflight_stream(self) -> None: 

1471 """Stop the streaming Textual worker AND interrupt its inference call. 

1472 

1473 Cancelling the Textual worker alone unwinds the producer task but 

1474 does not reach into the chat subprocess; the worker subprocess 

1475 keeps generating until ``Services.cancel_inference()`` flips its 

1476 abort flag (or sets the in-process Event in fallback mode). 

1477 """ 

1478 get_services().cancel_inference() 

1479 for worker in self.workers: 

1480 worker.cancel() 

1481 self.streaming = False 

1482 

1483 def apply_model_change(self) -> None: 

1484 """Cancel active stream (if any) and reset services for the new model.""" 

1485 if self.streaming: 

1486 self.action_cancel_stream() 

1487 self.call_later(self._deferred_service_reset) 

1488 else: 

1489 reset_services() 

1490 

1491 def _deferred_service_reset(self) -> None: 

1492 """Reset services once workers have drained.""" 

1493 if self.workers: 

1494 self.call_later(self._deferred_service_reset) 

1495 return 

1496 reset_services() 

1497 

1498 async def action_toggle_markdown(self) -> None: 

1499 """Toggle between Markdown and plain-text rendering for chat responses.""" 

1500 cfg.markdown_rendering = not cfg.markdown_rendering 

1501 use_md = cfg.markdown_rendering 

1502 chat_log = self._chat_log 

1503 for widget in chat_log.query(AssistantMessage): 

1504 await widget.rebuild_content_widget(use_md) 

1505 label = "Markdown" if use_md else "Plain text" 

1506 self.notify(msg.CHAT_RENDERING.format(label=label)) 

1507 

1508 def _run_sync(self, *, force_rebuild: bool = False) -> None: 

1509 """Enqueue a document sync (or full rebuild) in the task bar.""" 

1510 if self._sync_active: 

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

1512 return 

1513 from lilbee.cli.tui.task_queue import TaskType 

1514 

1515 self._sync_active = True 

1516 # Clear the pending hint so the bar shows live sync progress 

1517 # instead of the stale "N docs to sync" line. 

1518 self._task_bar.clear_pending_sync() 

1519 

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

1521 try: 

1522 self._do_sync(reporter, force_rebuild=force_rebuild) 

1523 finally: 

1524 self._sync_active = False 

1525 # Re-detect after every sync attempt: success drives the 

1526 # count to 0, failure or cancel leaves the still-pending 

1527 # files counted so the hint reappears. 

1528 self._task_bar.start_detect_pending() 

1529 

1530 label = msg.TASK_NAME_REBUILD if force_rebuild else msg.TASK_NAME_SYNC 

1531 self._task_bar.start_task(label, TaskType.SYNC, _target, indeterminate=True) 

1532 

1533 def _do_sync(self, reporter: ProgressReporter, *, force_rebuild: bool = False) -> None: 

1534 """Sync body. Runs on worker thread.""" 

1535 from lilbee.data.ingest import sync 

1536 

1537 reporter.update(0, msg.SYNC_STATUS_SYNCING, indeterminate=True) 

1538 on_progress = build_sync_progress_callback(reporter) 

1539 try: 

1540 result = asyncio_loop.run( 

1541 sync(quiet=True, on_progress=on_progress, force_rebuild=force_rebuild) 

1542 ) 

1543 except asyncio.CancelledError as exc: 

1544 raise RuntimeError(msg.SYNC_CANCELLED_RESUME) from exc 

1545 if result.failed: 

1546 raise RuntimeError(msg.SYNC_FAILED_FILES.format(files=", ".join(result.failed))) 

1547 if result.skipped: 

1548 call_from_thread( 

1549 self, 

1550 self.notify, 

1551 msg.sync_skipped_message(", ".join(result.skipped)), 

1552 severity="warning", 

1553 ) 

1554 

1555 def action_focus_commands(self) -> None: 

1556 """Focus chat input and pre-fill with '/' for command entry.""" 

1557 # Route through the helper so can_focus is re-enabled when this 

1558 # action fires from NORMAL mode; bare ``inp.focus()`` would 

1559 # silently no-op while the input is intentionally unfocusable. 

1560 self._enter_insert_mode() 

1561 inp = self._chat_input 

1562 if not inp.value.startswith("/"): 

1563 inp.value = "/" 

1564 inp.action_end() 

1565 

1566 def action_toggle_chat_mode(self) -> None: 

1567 """F3: flip between Search and Chat mode.""" 

1568 try: 

1569 toggle = self.query_one(ChatModeToggle) 

1570 except NoMatches: 

1571 return 

1572 if not toggle.toggle(): 

1573 return 

1574 label = ( 

1575 msg.CHAT_MODE_SEARCH_LABEL 

1576 if cfg.chat_mode == ChatMode.SEARCH.value 

1577 else msg.CHAT_MODE_CHAT_LABEL 

1578 ) 

1579 self.notify(msg.CHAT_MODE_SET.format(label=label)) 

1580 

1581 def action_cycle_scope(self) -> None: 

1582 """``s``: cycle the scope chip when it is currently visible.""" 

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

1584 

1585 try: 

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

1587 except NoMatches: 

1588 return 

1589 if chip.has_class("-hidden"): 

1590 return 

1591 chip.cycle_scope() 

1592 

1593 def action_complete(self) -> None: 

1594 """Tab: fill the shared prefix, then cycle matches (readline / vim style). 

1595 

1596 - Insert mode + chat input focused + dropdown closed but matches 

1597 exist: open it, fill the longest common prefix, else preview the 

1598 first match. 

1599 - Insert mode + chat input focused + dropdown open: fill any further 

1600 shared prefix, otherwise preview the next match. 

1601 - Insert mode + chat input focused + no matches: insert ``\\t`` so 

1602 users can type tab characters directly. 

1603 - Normal mode or focus elsewhere: advance through the focus 

1604 chain so Tab still walks every focusable widget. 

1605 """ 

1606 inp = self._chat_input 

1607 if not self._insert_mode or not inp.has_focus: 

1608 self.screen.focus_next() 

1609 return 

1610 overlay = self._completion_overlay 

1611 if not overlay.is_visible and not self._open_completions(): 

1612 inp.insert("\t") 

1613 return 

1614 if self._fill_common_prefix(): 

1615 return 

1616 self._preview_next() 

1617 

1618 def action_complete_next(self) -> None: 

1619 """Ctrl+N: preview the next match, opening the dropdown if it is closed (vim ``<C-n>``).""" 

1620 if not self._chat_input.has_focus: 

1621 return 

1622 if self._completion_overlay.is_visible or self._open_completions(): 

1623 self._preview_next() 

1624 

1625 def action_complete_prev(self) -> None: 

1626 """Ctrl+P: preview the previous match, opening the dropdown if it is closed.""" 

1627 if not self._chat_input.has_focus: 

1628 return 

1629 if self._completion_overlay.is_visible or self._open_completions(): 

1630 self._preview_prev() 

1631 

1632 def _preview_next(self) -> None: 

1633 """Preview the highlighted match if none is previewed yet, else step forward.""" 

1634 overlay = self._completion_overlay 

1635 if self._chat_input.value == self._completion_origin: 

1636 display = overlay.get_current() 

1637 else: 

1638 display = overlay.cycle_next() 

1639 if display is not None: 

1640 self._preview_completion(display) 

1641 

1642 def _preview_prev(self) -> None: 

1643 """Step the highlight backward (wrapping to the last match) and preview it.""" 

1644 display = self._completion_overlay.cycle_prev() 

1645 if display is not None: 

1646 self._preview_completion(display) 

1647 

1648 def _open_completions(self) -> bool: 

1649 """Show the dropdown for the current input and remember it as the origin.""" 

1650 options = get_completions(self._chat_input.value) 

1651 if not options: 

1652 return False 

1653 self._completion_origin = self._chat_input.value 

1654 self._completion_overlay.show_completions(options) 

1655 return True 

1656 

1657 def _completion_value(self, display: str) -> str: 

1658 """Full input text produced by accepting ``display``, keeping the typed prefix. 

1659 

1660 Path completions are basenames, so the directory the user already 

1661 typed (``~/``, ``./``, absolute) is preserved and only the final 

1662 segment is replaced. 

1663 """ 

1664 text = ( 

1665 self._completion_origin 

1666 if self._completion_origin is not None 

1667 else (self._chat_input.value) 

1668 ) 

1669 if " " not in text: 

1670 return display 

1671 cmd, _, partial = text.partition(" ") 

1672 if cmd.lower() == "/add": 

1673 head = path_completion_prefix(partial) 

1674 return f"{cmd} {head}{display}" 

1675 return f"{cmd} {display}" 

1676 

1677 def _set_input(self, value: str) -> None: 

1678 """Replace the input value without triggering the live-refresh of the dropdown.""" 

1679 inp = self._chat_input 

1680 if inp.value == value: 

1681 return 

1682 # The setter posts Changed asynchronously; flag one event to ignore so 

1683 # the previewed candidate doesn't re-filter (and collapse) the dropdown. 

1684 # (The value setter already moves the cursor to the end.) 

1685 self._suppress_refresh += 1 

1686 inp.value = value 

1687 

1688 def _preview_completion(self, display: str) -> None: 

1689 """Write the highlighted candidate into the input, leaving the dropdown open.""" 

1690 self._set_input(self._completion_value(display)) 

1691 

1692 def _fill_common_prefix(self) -> bool: 

1693 """Extend the input to the longest prefix shared by all matches; True if it grew.""" 

1694 overlay = self._completion_overlay 

1695 values = [self._completion_value(d) for d in overlay.options] 

1696 shared = longest_common_prefix(values) 

1697 if len(shared) <= len(self._chat_input.value): 

1698 return False 

1699 self._set_input(shared) 

1700 # Re-filter for the newly completed prefix (descends into a directory, 

1701 # narrows the model list, etc.). 

1702 self._refresh_completion_overlay() 

1703 return True 

1704 

1705 def action_history_prev(self) -> None: 

1706 """Up arrow: cycle the dropdown if visible, else recall previous history entry.""" 

1707 if not self._insert_mode: 

1708 raise SkipAction() 

1709 inp = self._chat_input 

1710 if not inp.has_focus: 

1711 raise SkipAction() 

1712 # When the completion dropdown is up, Up navigates the dropdown 

1713 # (vim/Emacs-style) rather than recalling history. 

1714 overlay = self._completion_overlay 

1715 if overlay.is_visible: 

1716 self._preview_prev() 

1717 return 

1718 if not self._input_history: 

1719 raise SkipAction() 

1720 if self._history_index == -1: 

1721 self._history_index = len(self._input_history) - 1 

1722 elif self._history_index > 0: 

1723 self._history_index -= 1 

1724 else: 

1725 return 

1726 inp.value = self._input_history[self._history_index] 

1727 inp.action_end() 

1728 

1729 def action_history_next(self) -> None: 

1730 """Down arrow: cycle the dropdown if visible, else recall next history entry.""" 

1731 if not self._insert_mode: 

1732 raise SkipAction() 

1733 inp = self._chat_input 

1734 if not inp.has_focus: 

1735 raise SkipAction() 

1736 # When the completion dropdown is up, Down navigates the dropdown. 

1737 overlay = self._completion_overlay 

1738 if overlay.is_visible: 

1739 self._preview_next() 

1740 return 

1741 if self._history_index == -1: 

1742 raise SkipAction() 

1743 if self._history_index < len(self._input_history) - 1: 

1744 self._history_index += 1 

1745 inp.value = self._input_history[self._history_index] 

1746 inp.action_end() 

1747 else: 

1748 self._history_index = -1 

1749 inp.value = "" 

1750 

1751 @on(ChatInput.Changed, "#chat-input") 

1752 def _on_chat_input_changed(self, event: ChatInput.Changed) -> None: 

1753 """Refresh arg-hint and auto-show or hide the completion dropdown.""" 

1754 if self._suppress_refresh > 0: 

1755 # A programmatic edit (preview / accept / revert) is managing the 

1756 # overlay itself; consume one Changed and skip the live refresh. 

1757 self._suppress_refresh -= 1 

1758 self._refresh_arg_hint() 

1759 return 

1760 self._refresh_completion_overlay() 

1761 self._refresh_arg_hint() 

1762 

1763 def _refresh_completion_overlay(self) -> None: 

1764 """Live-filter the dropdown against the current input, in command and arg modes alike.""" 

1765 overlay = self._completion_overlay 

1766 text = self._chat_input.value 

1767 options = get_completions(text) 

1768 if options: 

1769 self._completion_origin = text 

1770 overlay.show_completions(options) 

1771 elif overlay.is_visible: 

1772 overlay.hide() 

1773 self._completion_origin = None 

1774 

1775 def _refresh_arg_hint(self) -> None: 

1776 """Push the current input value into the ArgHintLine.""" 

1777 self._arg_hint.update_for_input(self._chat_input.value) 

1778 

1779 def refresh_model_bar(self) -> None: 

1780 """Re-scan installed models and refresh the model bar.""" 

1781 self.query_one("#model-bar", ModelBar).refresh_models() 

1782 

1783 def action_vim_scroll_down(self) -> None: 

1784 """Vim j: scroll down in normal mode.""" 

1785 if self._insert_mode: 

1786 raise SkipAction() 

1787 self._chat_log.scroll_down() 

1788 

1789 def action_vim_scroll_up(self) -> None: 

1790 """Vim k: scroll up in normal mode.""" 

1791 if self._insert_mode: 

1792 raise SkipAction() 

1793 self._chat_log.scroll_up() 

1794 

1795 def action_vim_scroll_home(self) -> None: 

1796 """Vim g: scroll to top in normal mode.""" 

1797 if self._insert_mode: 

1798 raise SkipAction() 

1799 self._chat_log.scroll_home() 

1800 

1801 def action_vim_scroll_end(self) -> None: 

1802 """Vim G: scroll to bottom in normal mode.""" 

1803 if self._insert_mode: 

1804 raise SkipAction() 

1805 self._chat_log.scroll_end() 

1806 

1807 def action_half_page_down(self) -> None: 

1808 """Ctrl-D: half-page down (vim style).""" 

1809 log_widget = self._chat_log 

1810 half = max(1, log_widget.size.height // 2) 

1811 log_widget.scroll_relative(y=half) 

1812 

1813 def action_half_page_up(self) -> None: 

1814 """Ctrl-U: half-page up (vim style).""" 

1815 log_widget = self._chat_log 

1816 half = max(1, log_widget.size.height // 2) 

1817 log_widget.scroll_relative(y=-half)