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
« 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."""
3from __future__ import annotations
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
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
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
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)
75if TYPE_CHECKING:
76 from lilbee.cli.tui.widgets.task_bar_controller import TaskBarController
77log = logging.getLogger(__name__)
79_HISTORY_TOKEN_BUDGET_FRACTION = 0.5
80"""Fraction of ``cfg.chat_n_ctx_target`` reserved for prior conversation history.
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"""
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
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
99# Auto-scroll throttle. ~6 fps so heavy token streams don't peg the renderer.
100_STREAM_SCROLL_INTERVAL = 0.15
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"
109class ChatWelcome(Static):
110 """Empty-state welcome posted into the chat log; removed on first message."""
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)
120class PromptArea(Vertical):
121 """Container for chat input that highlights on focus-within."""
123 pass
126class ChatScreen(Screen[None]):
127 """Primary chat interface with streaming LLM responses."""
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]
134 CSS_PATH = "chat.tcss"
135 AUTO_FOCUS = "#chat-input"
137 streaming: reactive[bool] = reactive(False)
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 )
146 _SCROLL_GROUP = Binding.Group("Scroll", compact=True)
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)
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 ]
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()
223 def _build_command_handlers(self) -> dict[str, Callable[[str], None]]:
224 """Bind every COMMANDS entry to its handler method on this instance.
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
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
238 @property
239 def _task_bar(self) -> TaskBarController:
240 """The app-level TaskBarController (always set by LilbeeApp)."""
241 return self.app.task_bar
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
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()
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()
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)
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
288 self.app.push_screen(SetupWizard(), self._on_setup_complete)
290 def on_show(self) -> None:
291 """Called when screen becomes visible."""
292 from lilbee.runtime.splash import dismiss
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()
307 def _needs_setup(self) -> bool:
308 """True when the setup wizard should run: fresh data dir or unresolved models.
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
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
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)
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()
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()
350 def action_open_setup(self) -> None:
351 """Open the setup wizard."""
352 self._cmd_setup("")
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()
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()
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
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
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
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.
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()
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.
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()
424 def on_click(self, event: events.Click) -> None:
425 """Click outside the chat input bar drops back to NORMAL.
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()
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
482 if text.startswith("/"):
483 self._handle_slash(text)
484 return
485 self._send_message(text)
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.
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
501 def _accept_overlay_selection_on_enter(self) -> bool:
502 """Accept the highlighted completion on Enter; True if Enter was consumed.
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
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")
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
538 def watch_streaming(self, streaming: bool) -> None:
539 if streaming:
540 self._enter_streaming_state()
541 else:
542 self._exit_streaming_state()
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()
550 def _exit_streaming_state(self) -> None:
551 self.remove_class("streaming")
552 self.refresh_bindings()
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)
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
597 names = ", ".join(p.name for p in duplicates)
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)
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 )
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
617 self._sync_active = True
618 label = paths[0].name if len(paths) == 1 else f"{len(paths)} files"
620 def _target(reporter: ProgressReporter) -> None:
621 try:
622 self._do_add(paths, reporter, force=force)
623 finally:
624 self._sync_active = False
626 self._task_bar.start_task(f"Add {label}", TaskType.ADD, _target, indeterminate=True)
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
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)
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)))
663 def _cmd_cancel(self, _args: str) -> None:
664 for worker in self.workers:
665 worker.cancel()
666 self.notify(msg.CMD_CANCEL)
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)
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 )
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
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 )
713 self.app.push_screen(CrawlDialog(), callback=_on_result)
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.
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
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)
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 )
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()
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
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)
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.
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
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
818 reporter.update(0, msg.CMD_CRAWL_STARTED.format(url=url))
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 )
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))
870 def _cmd_catalog(self, _args: str) -> None:
871 self.app.switch_view("Catalog")
872 from lilbee.cli.tui.screens.catalog import CatalogScreen
874 self.app.push_screen(CatalogScreen())
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())
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
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
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
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
909 get_services().store.remove_documents([name])
910 from lilbee.cli.tui.widgets.autocomplete import invalidate_document_cache
912 invalidate_document_cache()
913 call_from_thread(self, self.notify, msg.CMD_DELETE_SUCCESS.format(name=name))
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)
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
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 )
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)
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
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 )
968 def _cmd_help(self, _args: str) -> None:
969 self.action_show_command_catalog()
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)
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()
982 def _on_catalog_pick(self, name: str | None) -> None:
983 if name is None:
984 return
985 self.insert_slash_command(name)
987 def _cmd_login(self, args: str) -> None:
988 token = args.strip()
989 if not token:
990 import webbrowser
992 webbrowser.open("https://huggingface.co/settings/tokens")
993 self.notify(msg.CHAT_LOGIN_PROMPT)
994 return
995 self._run_hf_login(token)
997 @work(thread=True)
998 def _run_hf_login(self, token: str) -> None:
999 try:
1000 from huggingface_hub import login
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 )
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
1020 self.app.push_screen(CatalogScreen())
1022 def _cmd_quit(self, _args: str) -> None:
1023 self.app.exit()
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)
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 )
1054 def _cmd_rebuild(self, _args: str) -> None:
1055 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
1057 def _on_confirm(confirmed: bool | None) -> None:
1058 if not confirmed:
1059 return
1060 self._run_sync(force_rebuild=True)
1062 self.app.push_screen(
1063 ConfirmDialog(msg.CMD_REBUILD_CONFIRM_TITLE, msg.CMD_REBUILD_CONFIRM_MESSAGE),
1064 _on_confirm,
1065 )
1067 def _cmd_reset(self, args: str) -> None:
1068 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
1070 def _on_confirm(confirmed: bool | None) -> None:
1071 if not confirmed:
1072 return
1073 from lilbee.app.reset import perform_reset
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
1082 # Reopen LanceDB against the now-empty data dir; keep providers loaded.
1083 reset_store()
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)
1093 self.app.push_screen(
1094 ConfirmDialog(msg.CMD_RESET_CONFIRM_TITLE, msg.CMD_RESET_CONFIRM_MESSAGE),
1095 _on_confirm,
1096 )
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 ""
1105 if key not in SETTINGS_MAP:
1106 self.notify(msg.CMD_SET_UNKNOWN.format(key=key), severity="warning")
1107 return
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")
1128 def _cmd_settings(self, _args: str) -> None:
1129 self.app.switch_view("Settings")
1131 def _cmd_setup(self, _args: str) -> None:
1132 from lilbee.cli.tui.screens.setup import SetupWizard
1134 self.app.push_screen(SetupWizard(), self._on_setup_complete)
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)
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)
1146 def _cmd_memories(self, _args: str) -> None:
1147 from lilbee.cli.tui.screens.memories import MemoriesScreen
1149 self.app.push_screen(MemoriesScreen())
1151 def _cmd_status(self, _args: str) -> None:
1152 self.app.switch_view("Status")
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")
1162 def _cmd_version(self, _args: str) -> None:
1163 self.notify(msg.CHAT_VERSION.format(version=get_version()))
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")
1171 def _send_message(self, text: str) -> None:
1172 """Send a user message and stream the response."""
1173 from textual.css.query import NoMatches
1175 log = self._chat_log
1176 with contextlib.suppress(NoMatches):
1177 log.query_one("#chat-welcome", ChatWelcome).remove()
1178 log.mount(UserMessage(text))
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
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())
1195 def _current_chunk_type(self) -> ChunkType | None:
1196 """Translate the ScopeChip selection into a ``chunk_type`` arg.
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
1204 from lilbee.cli.tui.widgets.scope_chip import ScopeChip
1206 try:
1207 chip = self.query_one("#scope-chip", ScopeChip)
1208 except NoMatches:
1209 return None
1210 return scope_to_chunk_type(chip.scope)
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)
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))
1245 def _maybe_extract_memories(self, question: str, answer: str) -> None:
1246 """Spawn auto-extraction for the finished turn, when enabled and idle.
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
1253 if not answer or not auto_extract_enabled() or self._indexing_active():
1254 return
1255 self._extract_memories_worker(question, answer)
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
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)
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
1269 stored = auto_extract(question, answer)
1270 if stored:
1271 call_from_thread(self, self.notify, msg.MEMORY_AUTO_EXTRACTED.format(count=len(stored)))
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
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 )
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)
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)
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
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)
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]
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()
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()
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)
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
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)
1393 def _notify_no_results(self) -> None:
1394 self.notify(msg.CHAT_MODE_SEARCH_NO_RESULTS, severity="warning")
1396 def _trim_history(self) -> None:
1397 """Window history to a token budget. Caller must hold _history_lock.
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)
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
1422 def action_scroll_up(self) -> None:
1423 self._chat_log.scroll_page_up()
1425 def action_scroll_down(self) -> None:
1426 self._chat_log.scroll_page_down()
1428 def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
1429 """Keep the footer honest about mode-dependent bindings.
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)
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()
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()
1470 def _cancel_inflight_stream(self) -> None:
1471 """Stop the streaming Textual worker AND interrupt its inference call.
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
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()
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()
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))
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
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()
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()
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)
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
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 )
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()
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))
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
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()
1593 def action_complete(self) -> None:
1594 """Tab: fill the shared prefix, then cycle matches (readline / vim style).
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()
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()
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()
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)
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)
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
1657 def _completion_value(self, display: str) -> str:
1658 """Full input text produced by accepting ``display``, keeping the typed prefix.
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}"
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
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))
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
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()
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 = ""
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()
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
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)
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()
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()
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()
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()
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()
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)
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)