Coverage for src/lilbee/cli/tui/screens/chat.py: 100%
1519 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
1"""Chat screen: scrollable message log with streaming markdown responses."""
3from __future__ import annotations
5import asyncio
6import contextlib
7import difflib
8import logging
9import os
10import shlex
11import threading
12import time
13from collections.abc import Callable
14from dataclasses import dataclass, field
15from functools import partial
16from pathlib import Path
17from typing import TYPE_CHECKING, Any, ClassVar
19from rich.rule import Rule
20from textual import events, getters, on, work
21from textual.actions import SkipAction
22from textual.app import ComposeResult
23from textual.binding import Binding, BindingType
24from textual.containers import Horizontal, Vertical, VerticalScroll
25from textual.content import Content
26from textual.css.query import NoMatches
27from textual.dom import DOMNode
28from textual.message import Message
29from textual.reactive import reactive
30from textual.screen import Screen
31from textual.widget import Widget
32from textual.widgets import Footer, Markdown, Select, Static
34# Cancellation check for @work(thread=True) workers. Import at module level
35# since it's used in multiple methods.
36from textual.worker import NoActiveWorker
37from textual.worker import get_current_worker as _get_worker
39from lilbee.app.ingest import removable_names, remove_documents_durably
40from lilbee.app.services import get_services, reset_store
41from lilbee.app.session_export import write_session_markdown
42from lilbee.app.settings_map import SETTINGS_MAP
43from lilbee.app.themes import DARK_THEMES
44from lilbee.app.version import get_version
45from lilbee.cli.tui import messages as msg
46from lilbee.cli.tui.app import LilbeeApp, apply_active_model
47from lilbee.cli.tui.command_registry import runs_while_streaming
48from lilbee.cli.tui.log_routing import tui_log_path
49from lilbee.cli.tui.screens.chat_helpers import (
50 add_indexed_anything,
51 build_add_progress_callback,
52 build_import_progress_callback,
53 build_sync_progress_callback,
54 close_stream,
55 open_local_file,
56 remember_from_input,
57 unregister_added_roots,
58)
59from lilbee.cli.tui.thread_safe import call_from_thread, post_from_thread
60from lilbee.cli.tui.widgets.arg_hint import ArgHintLine
61from lilbee.cli.tui.widgets.autocomplete import (
62 PATH_ARG_COMMANDS,
63 CompletionOverlay,
64 get_completions,
65 longest_common_prefix,
66 path_completion_prefix,
67)
68from lilbee.cli.tui.widgets.chat_input import ChatInput
69from lilbee.cli.tui.widgets.context_chip import ContextChip
70from lilbee.cli.tui.widgets.drawer import drawer_holding
71from lilbee.cli.tui.widgets.fleet_body import FleetBody
72from lilbee.cli.tui.widgets.fleet_drawer import FleetDrawer
73from lilbee.cli.tui.widgets.fork_picker import ForkPicker, fork_points
74from lilbee.cli.tui.widgets.help_hint import HelpHint
75from lilbee.cli.tui.widgets.message import AssistantMessage, UserMessage
76from lilbee.cli.tui.widgets.model_bar import ChatModeToggle, ModelBar
77from lilbee.cli.tui.widgets.slash_command_catalog import SlashCommandCatalog
78from lilbee.cli.tui.widgets.status_bar import ViewTabs
79from lilbee.cli.tui.widgets.task_bar import TaskBar
80from lilbee.cli.tui.widgets.task_bar_controller import ProgressReporter
81from lilbee.core.config import cfg
82from lilbee.core.config.enums import ChatMode, CrawlRenderMode
83from lilbee.core.config.model import CLEARABLE_MODEL_FIELDS
84from lilbee.crawler import crawler_available, is_url, require_valid_crawl_url
85from lilbee.data.store import (
86 ChunkType,
87 EmbeddingModelMismatchError,
88 SearchScope,
89 scope_to_chunk_type,
90)
91from lilbee.providers.roles import WorkerRole
92from lilbee.providers.warm_progress import WarmPhase, WarmProgress
93from lilbee.retrieval.embedder import is_model_available
94from lilbee.retrieval.query import SOURCES_BLOCK_MARKER, ChatMessage
95from lilbee.retrieval.query.compaction import (
96 compaction_due,
97 foldable,
98 history_budget,
99 overflow,
100 prompt_history,
101 summary_messages,
102)
103from lilbee.retrieval.query.history_window import estimate_tokens
104from lilbee.retrieval.reasoning import RetrievalNotice
105from lilbee.runtime import asyncio_loop
106from lilbee.runtime.lock import ResetRefusedError
107from lilbee.runtime.progress import (
108 EventType,
109 ProgressEvent,
110)
111from lilbee.sessions import (
112 MessageRole,
113 Session,
114 SessionMessage,
115 SessionNotFoundError,
116 SessionOrigin,
117 SessionOwnershipError,
118 SessionStore,
119 TitleSource,
120 derive_title,
121)
123if TYPE_CHECKING:
124 from lilbee.cli.tui.widgets.task_bar_controller import TaskBarController
125log = logging.getLogger(__name__)
127# Coalesce per-token UI updates into ~50 ms windows. Tiny reasoning models can
128# emit 100+ tokens/sec; one ``call_from_thread`` per token saturates Textual's
129# message queue and makes key events visibly lag.
130_STREAM_FLUSH_INTERVAL = 0.05
133@dataclass
134class _StreamTimings:
135 """Last-fired monotonic timestamp for the stream flush."""
137 last_flush: float
140@dataclass
141class _Turn:
142 """One live question: its stop signal, answer bubble, conversation and session."""
144 question: str
145 widget: AssistantMessage
146 generation: int
147 session_id: str | None
148 stop: threading.Event = field(default_factory=threading.Event)
149 answer: str = ""
152class _TurnEnded(Message):
153 """Posted by a turn's worker once its body has ended, whether or not the answer saved."""
155 bubble = False
157 def __init__(self, turn: _Turn) -> None:
158 super().__init__()
159 self.turn = turn
162# The stream worker's group. Only Textual's teardown cancels it: a turn stops
163# through its stop signal, so its body always runs and always ends the turn.
164_TURN_WORKER_GROUP = "chat_turn"
167# ``/crawl`` command flags.
168_CRAWL_FLAG_DEPTH = "--depth"
169_CRAWL_FLAG_MAX_PAGES = "--max-pages"
170_CRAWL_FLAG_INCLUDE_SUBDOMAINS = "--include-subdomains"
171_CRAWL_FLAG_RENDER = "--render"
173# Name for the thread worker that resets and warms the new chat model off the
174# event loop.
175_MODEL_SWAP_WORKER = "model_swap_reset"
177# The NORMAL / INSERT mode keys (esc, i, a, o, enter). A focused drawer keeps them.
178_MODE_ACTIONS = frozenset({"enter_normal_mode", "insert_mode", "insert_or_send"})
181def _engine_status_text(snapshot: WarmProgress) -> str:
182 """One status line for an engine-load snapshot: byte progress or the phase."""
183 if snapshot.phase is WarmPhase.READING_WEIGHTS and snapshot.bytes_total:
184 from lilbee.catalog.formatting import display_label_for_ref
186 name = display_label_for_ref(snapshot.model_ref) if snapshot.model_ref else ""
187 pct = snapshot.bytes_done * 100 // snapshot.bytes_total
188 return f"{msg.ENGINE_READING_WEIGHTS.format(name=name)} {pct}%"
189 if snapshot.phase is WarmPhase.LOADING_ENGINE:
190 return msg.ENGINE_ALMOST_READY
191 return msg.ENGINE_WARMING
194_SETTING_TYPE_HINTS: dict[type, str] = {int: "a whole number", float: "a number"}
197def _stream_error_text(exc: Exception) -> str:
198 """The note for a failed answer; a severed engine socket names the problem, not the OS error."""
199 if isinstance(exc, ConnectionError):
200 return msg.STREAM_DISCONNECTED
201 return msg.STREAM_ERROR.format(error=exc)
204def _setting_type_hint(kind: type) -> str:
205 """Human phrase for what a settings value must be."""
206 return _SETTING_TYPE_HINTS.get(kind, f"a valid {kind.__name__} value")
209def _closest_source(name: str, known: set[str]) -> str | None:
210 """The indexed name most likely meant by *name*, or None when nothing is close."""
211 low = name.lower()
212 contains = [k for k in known if low in k.lower()]
213 if len(contains) == 1:
214 return contains[0]
215 matches = difflib.get_close_matches(name, sorted(known), n=1, cutoff=0.6)
216 return matches[0] if matches else None
219def _parse_add_paths(args: str) -> list[Path]:
220 """Resolve ``/add`` arguments to filesystem paths.
222 A single unquoted path may contain spaces and apostrophes (e.g. macOS
223 "Star Wars Collector's Edition.pdf"), which shell parsing would split into
224 fragments or reject with "No closing quotation". So when the whole argument
225 points at an existing file or directory, take it as one path; otherwise fall
226 back to shell-style splitting for multiple, optionally quoted, paths.
227 """
228 whole = Path(args.strip().strip('"').strip("'")).expanduser()
229 if whole.exists():
230 return [whole]
231 try:
232 # posix=False on Windows keeps backslash path separators literal.
233 tokens = shlex.split(args, posix=os.name != "nt")
234 except ValueError:
235 return [whole] # unbalanced quote in a literal path; treat as one path
236 if os.name == "nt":
237 tokens = [t.strip('"').strip("'") for t in tokens]
238 return [Path(token).expanduser() for token in tokens]
241class ChatWelcome(Static):
242 """Empty-state welcome posted into the chat log; removed on first message."""
244 def __init__(self, *, id: str | None = None) -> None:
245 super().__init__(self._body(msg.CHAT_WELCOME_HINT), id=id)
247 @staticmethod
248 def _body(hint_text: str) -> Content:
249 title = Content.styled(msg.CHAT_WELCOME_TITLE, "bold $primary")
250 tagline = Content.styled(msg.CHAT_WELCOME_TAGLINE, "$text-muted")
251 hint = Content.styled(hint_text, "$text-muted")
252 return Content.assemble(title, "\n", tagline, "\n\n", hint)
254 def set_no_model(self, no_model: bool) -> None:
255 """Swap the hint line between "just ask" and the route to a chat model."""
256 hint = msg.CHAT_WELCOME_NO_MODEL_HINT if no_model else msg.CHAT_WELCOME_HINT
257 self.update(self._body(hint))
260class PromptArea(Vertical):
261 """Container for chat input that highlights on focus-within."""
263 pass
266class ChatScreen(Screen[None]):
267 """Primary chat interface with streaming LLM responses."""
269 # Lilbee always hosts screens on a LilbeeApp (production + LilbeeAppHost
270 # in tests), so narrowing the type lets the screen call set_theme /
271 # switch_view / task_bar without isinstance dance or # type: ignore.
272 app: LilbeeApp # type: ignore[assignment]
274 CSS_PATH = "chat.tcss"
275 AUTO_FOCUS = "#chat-input"
277 # True from the start of a turn until its worker has ended it, so it also
278 # covers a stopped turn whose body is still unwinding.
279 streaming: reactive[bool] = reactive(False)
280 # True once the live turn was asked to stop; hides the Ctrl+C cancel.
281 stopping: reactive[bool] = reactive(False)
282 # True while a chat-model swap's fleet reload runs in the background. Gates the
283 # submit handler and disables the input so the user can't fire a prompt into a
284 # half-loaded fleet; cleared when the swap worker finishes (or fails).
285 swapping_model: reactive[bool] = reactive(False)
286 # True while a placement apply/clear reloads the fleet (from the Fleet drawer);
287 # holds chat submissions so they don't race the reload into a 429.
288 reloading_placement: reactive[bool] = reactive(False)
290 HELP = (
291 "# Chat\n\n"
292 "Ask questions about your knowledge base.\n\n"
293 "Press **Escape** for normal mode (vim keys), "
294 "**i**/**a**/**o** to return to insert mode.\n\n"
295 "**/** opens the slash-command line and **Tab** completes what you "
296 "type there; **F2** lists every command.\n\n"
297 "**F6** jumps to the model strip under the prompt, and in normal mode "
298 "**h** / **l** or **Left** / **Right** step into it from either end. "
299 "**Left** / **Right** walk all six cells (the Chat, Embed, Vision and "
300 "Rerank pickers, then the Search and Chat mode pills), **h** / **l** do "
301 "the same, **Home** / **End** jump to either end, **Enter** opens or "
302 "picks the focused cell, **Escape** goes back."
303 )
305 _SCROLL_GROUP = Binding.Group("Scroll", compact=True)
307 # Hot-path widget refs. ``getters.query_one`` is a typed class-level
308 # descriptor that resolves via Textual's indexed DOM lookup on every
309 # access. It is O(1) for id selectors, so no cache is needed.
310 _chat_input = getters.query_one("#chat-input", ChatInput)
311 _chat_log = getters.query_one("#chat-log", VerticalScroll)
312 _completion_overlay = getters.query_one("#completion-overlay", CompletionOverlay)
313 _arg_hint = getters.query_one("#arg-hint", ArgHintLine)
315 BINDINGS: ClassVar[list[BindingType]] = [
316 # `/` opens the slash-command line: the one thing this screen is for
317 # besides typing, so it keeps a footer cell.
318 Binding("slash", "focus_commands", "Commands", show=True),
319 # F2 opens the searchable list of every slash command
320 # (SlashCommandCatalog) -- not the model catalog, which is `/models`.
321 # Help-panel only: `/` already leads there, and the full list is a lookup.
322 Binding(
323 "f2",
324 "show_command_catalog",
325 "All commands",
326 show=False,
327 priority=True,
328 ),
329 # Hidden: Tab only completes while the slash dropdown is open, and the
330 # rest of the time it walks the focus chain, so a permanent
331 # "tab Complete" cell overstated it. Named in help beside `/`.
332 Binding("tab", "complete", "Complete", show=False, priority=True),
333 Binding("ctrl+n", "complete_next", "Next match", show=False, priority=True),
334 # Ctrl+P stays bound to the app's command palette by default. The
335 # chat screen only intercepts it WHEN the dropdown is visible, via
336 # LilbeeApp.action_command_palette overriding to call
337 # ChatScreen.action_complete_prev. Action is exposed for direct
338 # callers / tests; not bound here so the app-level priority binding
339 # for ctrl+p (palette) wins by default.
340 Binding("pageup", "scroll_up", "PgUp", show=False, group=_SCROLL_GROUP),
341 Binding("pagedown", "scroll_down", "PgDn", show=False, group=_SCROLL_GROUP),
342 Binding("ctrl+d", "half_page_down", "^d half PgDn", show=False, group=_SCROLL_GROUP),
343 Binding("ctrl+u", "half_page_up", "^u half PgUp", show=False, group=_SCROLL_GROUP),
344 Binding("j", "vim_scroll_down", "j down", show=False, group=_SCROLL_GROUP),
345 Binding("k", "vim_scroll_up", "k up", show=False, group=_SCROLL_GROUP),
346 Binding("g", "vim_scroll_home", "g top", show=False, group=_SCROLL_GROUP),
347 Binding("G", "vim_scroll_end", "G bottom", show=False, group=_SCROLL_GROUP),
348 # priority=True keeps history navigation fast-path winning over the
349 # ChatInput's TextArea cursor_up/_down. Multi-line cursor movement
350 # inside the prompt still works via PgUp/PgDn/Home/End.
351 Binding("up", "history_prev", "Up", show=False, priority=True),
352 Binding("down", "history_next", "Down", show=False, priority=True),
353 # Esc always drops back into NORMAL mode so the user can navigate
354 # the terminal. Cancel-while-streaming is on Ctrl+C below; the
355 # two roles used to share Esc and clobbered each other.
356 Binding("escape", "enter_normal_mode", "Normal mode", show=True, priority=True),
357 # Ctrl+C cancels the active stream when streaming AND in INSERT
358 # mode so the user can interrupt without leaving the input. The
359 # screen-level priority binding overrides the App-level Quit;
360 # check_action below hides + disables it outside that exact
361 # context, so Ctrl+C still quits the app from NORMAL or when
362 # nothing is streaming.
363 Binding("ctrl+c", "cancel_stream", "Cancel stream", show=True, priority=True),
364 Binding("ctrl+r", "toggle_markdown", "Markdown", show=False),
365 Binding("s", "cycle_scope", "Scope", show=False),
366 Binding("f3", "toggle_chat_mode", "Search/Chat", show=False),
367 # A function key, not a letter: the four role pickers are worth
368 # reaching mid-sentence, and a focused input consumes printable keys
369 # before any binding fires. Tab reaches the bar too, but only from
370 # NORMAL mode and only after walking past the log.
371 Binding("f6", "focus_model_bar", "Model bar", show=False, priority=True),
372 # NORMAL mode walks sideways into the role strip. h / l rather than the
373 # whole of hjkl: the transcript owns j / k for scrolling. The NORMAL
374 # mode keys that move focus (h, l, i, a, o and Enter) are priority
375 # bindings, so focus moves in the key's own step and the keys typed
376 # after them follow it.
377 Binding("h", "enter_model_strip(-1)", "Prev role", show=False, priority=True),
378 Binding("l", "enter_model_strip(1)", "Next role", show=False, priority=True),
379 Binding("i", "insert_mode", "Insert", show=False, priority=True),
380 Binding("a", "insert_mode", "Insert", show=False, priority=True),
381 Binding("o", "insert_mode", "Insert", show=False, priority=True),
382 Binding("enter", "insert_or_send", "Insert", show=False, priority=True),
383 # The arrows reach here too. The focused transcript is a VerticalScroll
384 # and binds Left / Right to horizontal scrolling, but Widget's
385 # action_scroll_left raises SkipAction when there is nothing to scroll
386 # sideways, which resumes the key lookup and lands it here. A transcript
387 # wide enough to scroll keeps its own arrows; h / l are unconditional.
388 Binding("left", "enter_model_strip(-1)", "Prev role", show=False),
389 Binding("right", "enter_model_strip(1)", "Next role", show=False),
390 ]
392 def __init__(self) -> None:
393 super().__init__()
394 self._history: list[ChatMessage] = []
395 # Rolling summary of the turns compaction has folded out of _history.
396 # Guarded by _history_lock alongside the turns it stands in for.
397 self._summary = ""
398 self._history_lock = threading.Lock()
399 # Which conversation _history holds; bumped under _history_lock whenever
400 # it is replaced, so a turn's late fold can tell it no longer applies.
401 self._conversation_generation = 0
402 # The saved session this conversation persists to. None until the first
403 # user turn creates one; reset to None on /clear so the next turn opens a
404 # fresh session.
405 self._session_id: str | None = None
406 # The live turn; set with the busy flag and cleared only when it ends.
407 self._turn: _Turn | None = None
408 self._insert_mode: bool = True
409 # Count of programmatic input edits whose (async) Changed events should
410 # not re-filter the dropdown. The setter posts Changed after our flag
411 # window would close, so a counter consumed in the handler is used.
412 self._suppress_refresh = 0
413 # The user-typed text the open dropdown is filtering against. While
414 # navigating, the input holds a previewed candidate; Esc restores this.
415 self._completion_origin: str | None = None
416 self._sync_active: bool = False
417 self._input_history: list[str] = []
418 self._history_index: int = -1
419 # The warm tip is worth one toast per session, on the first prompt that
420 # has to wait out a cold engine load.
421 self._warm_tip_shown: bool = False
422 # The latest turn's question; a context boundary mounts above it, never
423 # after it. Outlives its turn: the next send overwrites it and a reset
424 # clears it.
425 self._active_question: UserMessage | None = None
426 # A model switch asked for mid-answer, applied once the stream ends.
427 self._model_switch_queued: bool = False
428 self._command_handlers: dict[str, Callable[[str], None]] = self._build_command_handlers()
430 def _build_command_handlers(self) -> dict[str, Callable[[str], None]]:
431 """Bind every COMMANDS entry to its handler method on this instance.
433 Run once at construction so /handle_slash dispatches via direct method
434 reference (no per-call getattr-by-string-name reflection).
435 """
436 from lilbee.cli.tui.command_registry import COMMANDS
438 handlers: dict[str, Callable[[str], None]] = {}
439 for cmd in COMMANDS:
440 method = getattr(self, cmd.handler)
441 for name in (cmd.name, *cmd.aliases):
442 handlers[name] = method
443 return handlers
445 @property
446 def _task_bar(self) -> TaskBarController:
447 """The app-level TaskBarController (always set by LilbeeApp)."""
448 return self.app.task_bar
450 def compose(self) -> ComposeResult:
451 from lilbee.cli.tui.widgets.bottom_bars import BottomBars
452 from lilbee.cli.tui.widgets.scope_chip import ScopeChip
453 from lilbee.cli.tui.widgets.top_bars import TopBars
455 with TopBars():
456 yield ViewTabs()
457 yield VerticalScroll(
458 ChatWelcome(id="chat-welcome"),
459 id="chat-log",
460 )
461 with BottomBars():
462 # Sits directly above the prompt area so it never covers the line
463 # you're typing (the input stays pinned to the bottom edge).
464 yield CompletionOverlay(id="completion-overlay")
465 with PromptArea(id="chat-prompt-area"):
466 yield ScopeChip(id="scope-chip")
467 yield ChatInput(
468 placeholder=msg.CHAT_INPUT_PLACEHOLDER_DEFAULT,
469 id="chat-input",
470 )
471 yield ArgHintLine(id="arg-hint")
472 yield ModelBar(id="model-bar")
473 yield TaskBar()
474 # The context reading shares the hint band instead of costing the
475 # prompt block its own row.
476 with Horizontal(id="hint-row"):
477 yield HelpHint(id="help-hint")
478 yield ContextChip(id="context-chip")
479 yield Footer()
481 def on_mount(self) -> None:
482 self._update_input_style()
483 self.app.settings_changed_signal.subscribe(self, self._on_settings_changed)
484 # init=True paints the empty state on first mount when the gate landed
485 # the app on the catalog and the user navigated here without a model.
486 self.watch(self.app, "chat_is_ready", self._on_chat_ready_changed, init=True)
488 def on_show(self) -> None:
489 """Called when screen becomes visible."""
490 from lilbee.runtime.splash import dismiss
492 dismiss()
493 self.refresh_model_bar()
494 # AUTO_FOCUS only fires once on initial mount. Re-entering the
495 # screen via view-nav needs an explicit focus restore. In INSERT
496 # mode we send focus to the chat input; in NORMAL mode we send
497 # focus to the chat log (the input is intentionally unfocusable
498 # so global bindings keep firing).
499 with contextlib.suppress(Exception):
500 if self._insert_mode:
501 self._enter_insert_mode()
502 else:
503 self.set_focus(self._chat_log)
505 def _embedding_ready(self) -> bool:
506 """Quick check if the embedding model resolves (no network calls)."""
507 return is_model_available(cfg.embedding_model, get_services().provider)
509 def _on_settings_changed(self, payload: tuple[str, object]) -> None:
510 key, _value = payload
511 if key in {"chat_mode", "embedding_model"}:
512 self.refresh_model_bar()
514 def _on_chat_ready_changed(self, ready: bool) -> None:
515 """Paint or clear the no-model empty state as readiness changes."""
516 with contextlib.suppress(NoMatches):
517 self.query_one("#chat-welcome", ChatWelcome).set_no_model(not ready)
518 self._apply_input_busy_state()
520 def _enter_insert_mode(self) -> None:
521 """Switch to insert mode: focus input, update border style."""
522 self._insert_mode = True
523 self._chat_input.can_focus = True
524 self.set_focus(self._chat_input)
525 self._update_input_style()
527 def focus_prompt(self) -> None:
528 """Focus the chat input in INSERT mode so the next keys type a prompt."""
529 self._enter_insert_mode()
531 def default_focus_target(self) -> Widget:
532 """This mode's own focus target: the prompt in INSERT, the transcript in NORMAL."""
533 return self._chat_input if self._insert_mode else self._chat_log
535 def action_focus_model_bar(self) -> None:
536 """F6: put the cursor on the model strip. Left / Right walk it from there."""
537 self.query_one("#model-bar", ModelBar).focus_strip()
539 def action_enter_model_strip(self, direction: int) -> None:
540 """h / l and the arrows from NORMAL mode: step in from the matching side.
542 Only reached while focus is outside the bar. Once a role holds the
543 cursor the bar's own keys win, being nearer the focus.
544 """
545 self.query_one("#model-bar", ModelBar).focus_strip(direction)
547 def run_command(self, text: str) -> None:
548 """Dispatch *text* as a slash command, as if submitted from the prompt."""
549 if self._reject_submit_when_busy(text):
550 return
551 self._handle_slash(text)
553 def _update_input_style(self) -> None:
554 """Toggle input opacity and mode indicator based on current mode."""
555 # Lifecycle interleaves (an installed-but-swapped-away screen during
556 # app teardown) can invoke this before or after the input exists.
557 with contextlib.suppress(NoMatches):
558 inp = self._chat_input
559 if self._insert_mode:
560 inp.remove_class("normal-mode")
561 else:
562 inp.add_class("normal-mode")
563 self._update_mode_indicator()
565 def _update_mode_indicator(self) -> None:
566 """Update the ViewTabs mode text to reflect the current mode."""
567 with contextlib.suppress(NoMatches):
568 bar = self.query_one(ViewTabs)
569 bar.mode_text = msg.MODE_INSERT if self._insert_mode else msg.MODE_NORMAL
571 def on_key(self, event: events.Key) -> None:
572 """In INSERT mode, type a printable key into the prompt when focus sits elsewhere."""
573 inp = self._chat_input
574 if self._insert_mode and not inp.has_focus and event.is_printable and event.character:
575 self.set_focus(inp)
576 inp.insert(event.character)
577 event.prevent_default()
578 event.stop()
580 def action_insert_mode(self) -> None:
581 """i / a / o from NORMAL mode: back to INSERT with the prompt focused."""
582 self._enter_insert_mode()
584 def action_insert_or_send(self) -> None:
585 """Enter from NORMAL mode: back to INSERT, sending the draft the user Esc'd over."""
586 self._enter_insert_mode()
587 inp = self._chat_input
588 if inp.value.strip():
589 self._submit_draft(inp, inp.value)
591 def _leaves_normal_mode(self, action: str) -> bool:
592 """True when a NORMAL mode key should return to INSERT.
594 The prompt keeps its own keys. A focused Select or
595 model-strip member keeps Enter; asked of the bar rather than of a list of
596 widget types, so Enter on a mode pill switches the mode instead of
597 dropping to INSERT.
598 """
599 if self._insert_mode or self._chat_input.has_focus:
600 return False
601 if action == "insert_or_send":
602 return not (isinstance(self.focused, Select) or self._focus_in_model_bar())
603 return True
605 @on(events.DescendantFocus, "#chat-input")
606 def _on_chat_input_focused(self, event: events.DescendantFocus) -> None:
607 """Mark INSERT mode whenever the chat input takes focus.
609 With ``can_focus = False`` while in NORMAL mode, the only way the
610 input gains focus is via an explicit user action (click, or the
611 :meth:`_enter_insert_mode` helper that sets ``can_focus = True``
612 and focuses the input). Either path implies INSERT, so we sync
613 the screen mode here.
614 """
615 if not self._insert_mode:
616 self._enter_insert_mode()
618 @on(events.Click, "#chat-input")
619 def _on_chat_input_clicked(self, event: events.Click) -> None:
620 """Click on the chat input bar promotes to INSERT.
622 ``can_focus = False`` while in NORMAL mode swallows focus from the
623 click, so DescendantFocus never fires. Hook the Click directly so
624 a mouse user lands in INSERT just like a keystroke (i / a / o).
625 """
626 if not self._insert_mode:
627 self._enter_insert_mode()
628 event.stop()
630 def on_click(self, event: events.Click) -> None:
631 """Click outside the chat input bar drops back to NORMAL.
633 The chat-input click handler above promotes to INSERT; the
634 symmetric exit happens here so a mouse user gets the same
635 click-to-blur behavior they expect from any other text editor.
636 """
637 if not self._insert_mode:
638 return
639 if event.widget is None:
640 return
641 chat_input = self._chat_input
642 node: DOMNode | None = event.widget
643 while node is not None:
644 if node is chat_input:
645 return
646 node = node.parent
647 self.action_enter_normal_mode()
649 @on(ChatInput.Submitted, "#chat-input")
650 def _on_chat_submitted(self, event: ChatInput.Submitted) -> None:
651 if not self._insert_mode:
652 # Vim-style: Enter in normal mode flips back to insert without
653 # submitting whatever empty / stale text the input still holds.
654 self._enter_insert_mode()
655 return
656 self._submit_draft(event.chat_input, event.value)
658 def _submit_draft(self, chat_input: ChatInput, value: str) -> None:
659 """Send *value* as a command or message once the submit gate allows it."""
660 text = value.strip()
661 if not self._ready_to_submit(text):
662 return
663 chat_input.value = ""
664 self._input_history.append(text)
665 self._history_index = -1
667 if text.startswith("/"):
668 self._handle_slash(text)
669 return
670 self._send_message(text)
672 def _ready_to_submit(self, text: str) -> bool:
673 """Gate a submit: busy, consumed, empty, and keep-the-draft cases say no."""
674 if self._reject_submit_when_busy(text) or self._dismiss_overlay_on_submit() or not text:
675 return False
676 cmd = self._slash_name(text)
677 if cmd:
678 if cmd not in self._command_handlers:
679 # Keep the draft so a typo (or a stale leading slash) can be
680 # fixed in place instead of retyped.
681 self.notify(msg.CMD_UNKNOWN.format(cmd=cmd), severity="warning")
682 return False
683 return True
684 pending = self._pending_required_model_download()
685 if pending is not None:
686 # Keep the typed prompt in the input so the user can submit it
687 # again once the download finishes, instead of retyping it.
688 self.notify(
689 msg.CHAT_MODEL_DOWNLOADING.format(name=pending),
690 severity="warning",
691 timeout=5,
692 )
693 return False
694 return True
696 def _reject_submit_when_busy(self, text: str = "") -> bool:
697 """Toast and reject a submit while a swap is loading or a stream is in flight.
699 Returns True when the submit was rejected so the caller stops. The swap
700 check comes first: a prompt sent mid-swap would race a half-torn-down
701 fleet, so the user is asked to wait rather than cancel. Commands the
702 registry marks ``allowed_while_streaming`` pass the streaming check, so
703 /cancel and /model stay reachable during the turn they act on.
704 """
705 if self.swapping_model:
706 self.notify(msg.CHAT_MODEL_SWITCHING, severity="warning", timeout=3)
707 return True
708 if self.reloading_placement:
709 self.notify(msg.FLEET_RELOADING, severity="warning", timeout=3)
710 return True
711 if self.streaming and not runs_while_streaming(self._slash_name(text)):
712 self._notify_busy()
713 return True
714 return False
716 def _notify_busy(self) -> None:
717 """Say why a submit was refused: the turn is answering, or still stopping."""
718 text = msg.CHAT_STOPPING if self.stopping else msg.CHAT_BUSY
719 self.notify(text, severity="warning", timeout=3)
721 @staticmethod
722 def _slash_name(text: str) -> str:
723 """The command word of a slash submit, or "" when *text* is not one."""
724 return text.split()[0].lower() if text.startswith("/") else ""
726 def _pending_required_model_download(self) -> str | None:
727 """Return the in-flight download's name if it's for the configured chat or embedding model.
729 Covers the fresh-install case where the default ``cfg.chat_model``
730 points at a featured catalog ref whose file isn't on disk yet,
731 but a wizard-triggered download for it is queued or active.
732 """
733 task_bar = self.app.task_bar
734 for ref in (cfg.chat_model, cfg.embedding_model):
735 label = task_bar.downloading_label_for(ref)
736 if label is not None:
737 return label
738 return None
740 def _dismiss_overlay_on_submit(self) -> bool:
741 """Close the dropdown on Enter; consume only a bare slash, never a message.
743 Enter submits exactly what was typed. Tab and the arrow keys are the
744 completion gestures, and a previewed candidate is already in the input,
745 so a highlighted-but-unaccepted suggestion must never rewrite or swallow
746 a submission.
747 """
748 overlay = self._completion_overlay
749 if overlay.is_visible:
750 overlay.hide()
751 self._completion_origin = None
752 if self._chat_input.value.strip() == "/":
753 self._set_input("")
754 return True
755 return False
757 def _handle_slash(self, text: str) -> None:
758 """Dispatch slash commands via the per-instance handler registry."""
759 cmd = text.split()[0].lower()
760 args = text[len(cmd) :].strip()
761 handler = self._command_handlers.get(cmd)
762 if handler is not None:
763 handler(args)
764 else:
765 self.notify(msg.CMD_UNKNOWN.format(cmd=cmd), severity="warning")
767 def watch_streaming(self, streaming: bool) -> None:
768 if streaming:
769 self._enter_streaming_state()
770 else:
771 self._exit_streaming_state()
773 def watch_stopping(self, _stopping: bool) -> None:
774 self.refresh_bindings()
776 def _enter_streaming_state(self) -> None:
777 self.add_class("streaming")
778 self.refresh_bindings()
780 def _exit_streaming_state(self) -> None:
781 self.remove_class("streaming")
782 self.refresh_bindings()
783 if self._model_switch_queued:
784 # Cleared before the call: apply_model_change can re-enter here.
785 self._model_switch_queued = False
786 self.apply_model_change()
788 def _cmd_add(self, args: str) -> None:
789 from lilbee.app.ingest import source_label_taken
791 if not args:
792 return
793 if self._sync_active:
794 self.notify(msg.SYNC_ALREADY_ACTIVE, severity="warning")
795 return
796 if is_url(args):
797 self._cmd_crawl(args)
798 return
799 paths = _parse_add_paths(args)
800 missing = [p for p in paths if not p.exists()]
801 if missing:
802 self.notify(
803 msg.CMD_ADD_NOT_FOUND.format(path=", ".join(str(p) for p in missing)),
804 severity="error",
805 )
806 return
807 # A file add registers a root labeled by its basename. Prompt before
808 # overwriting only when that label is already taken by a different source
809 # (a live root elsewhere, or an owned file of that name); re-adding the
810 # same path is idempotent, and a directory is left to register_sources'
811 # own skipped notices rather than a duplicate-file prompt.
812 duplicates = [p for p in paths if p.is_file() and source_label_taken(p.name, p)]
813 if duplicates:
814 self._prompt_overwrite(paths, duplicates)
815 return
816 self._submit_add(paths, force=False)
818 def _prompt_overwrite(self, paths: list[Path], duplicates: list[Path]) -> None:
819 """Ask to overwrite existing copies before re-syncing."""
820 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
822 names = ", ".join(p.name for p in duplicates)
824 def _on_confirm(confirmed: bool | None) -> None:
825 if not confirmed:
826 self.notify(msg.CMD_ADD_SKIPPED_DUPLICATE.format(name=names))
827 return
828 self._submit_add(paths, force=True)
830 self.app.push_screen(
831 ConfirmDialog(
832 msg.CMD_ADD_DUPLICATE_TITLE,
833 msg.CMD_ADD_DUPLICATE_MESSAGE.format(name=names),
834 ),
835 _on_confirm,
836 )
838 def _submit_add(self, paths: list[Path], *, force: bool) -> None:
839 """Spawn the add worker. Separated so overwrite confirm can reuse it."""
840 from lilbee.cli.tui.task_queue import TaskType
842 self._sync_active = True
843 label = paths[0].name if len(paths) == 1 else f"{len(paths)} files"
845 def _target(reporter: ProgressReporter) -> None:
846 try:
847 self._do_add(paths, reporter, force=force)
848 finally:
849 self._sync_active = False
851 self._task_bar.start_task(f"Add {label}", TaskType.ADD, _target, indeterminate=True)
853 def _do_add(
854 self, paths: list[Path], reporter: ProgressReporter, *, force: bool = False
855 ) -> None:
856 """Register source roots and run sync. Called on worker thread with a reporter."""
857 from lilbee.app.ingest import register_sources
858 from lilbee.data.ingest import sync
860 label = paths[0].name if len(paths) == 1 else f"{len(paths)} files"
861 reporter.update(0, f"Adding {label}...", indeterminate=True)
862 reg_result = register_sources(paths, force=force)
863 registered = reg_result.registered
864 for name in reg_result.name_taken:
865 call_from_thread(self, self.notify, msg.CMD_ADD_NAME_TAKEN.format(name=name))
866 if reg_result.tracked:
867 call_from_thread(
868 self, self.notify, msg.CMD_ADD_TRACKED.format(names=", ".join(reg_result.tracked))
869 )
870 if reg_result.overlapping:
871 names = ", ".join(reg_result.overlapping)
872 call_from_thread(self, self.notify, msg.CMD_ADD_OVERLAPPING.format(names=names))
873 if not reg_result.reached_corpus:
874 call_from_thread(self, self.notify, msg.CMD_ADD_NOTHING, severity="warning")
875 return
876 reporter.update(0, f"Added {len(registered)} source(s), syncing...", indeterminate=True)
878 try:
879 sync_result = asyncio_loop.run(
880 sync(quiet=True, on_progress=build_add_progress_callback(reporter))
881 )
882 except BaseException:
883 # On cancel or any failure, un-register the roots this /add created so
884 # the next sync doesn't silently re-ingest the source the user just
885 # cancelled. Only entries this invocation created are dropped;
886 # sources the user put in documents/ themselves are never touched.
887 unregister_added_roots(registered)
888 raise
889 if sync_result.failed:
890 unregister_added_roots(registered)
891 raise RuntimeError(msg.SYNC_FAILED_FILES.format(files=", ".join(sync_result.failed)))
892 if sync_result.skipped:
893 # Files yielding no text beside indexed siblings are a partial
894 # success; only an add whose own roots contributed nothing failed.
895 skipped_msg = msg.sync_skipped_message(sync_result, tui_log_path())
896 if registered and not add_indexed_anything(registered, sync_result):
897 unregister_added_roots(registered)
898 raise RuntimeError(skipped_msg)
899 call_from_thread(self, self.notify, skipped_msg, severity="warning")
900 if sync_result.relocated:
901 call_from_thread(
902 self,
903 self.notify,
904 msg.CMD_ADD_RELOCATED.format(count=len(sync_result.relocated)),
905 )
906 call_from_thread(self, self.notify, msg.CMD_ADD_SUCCESS.format(count=len(registered)))
908 def _cmd_cancel(self, _args: str) -> None:
909 self._stop_turn()
910 for worker in self.workers:
911 if worker.group != _TURN_WORKER_GROUP:
912 worker.cancel()
913 self.notify(msg.CMD_CANCEL)
915 def _cmd_clear(self, _args: str) -> None:
916 self._reset_conversation()
917 self.notify(msg.CMD_CLEAR)
919 def _reset_conversation(self) -> None:
920 """Stop the live turn, empty the log and history, and drop the active session.
922 The current session is already persisted, so dropping the id just makes the
923 next user turn open a fresh one.
924 """
925 self._stop_turn()
926 self._chat_log.remove_children()
927 self._active_question = None
928 with self._history_lock:
929 self._history.clear()
930 # A new conversation inherits nothing, least of all the last one's
931 # summary: carrying it would leak the old chat into the new prompt.
932 self._summary = ""
933 self._conversation_generation += 1
934 self._session_id = None
936 def _cmd_crawl(self, args: str) -> None:
937 if not crawler_available():
938 self.notify(msg.CMD_CRAWL_UNAVAILABLE, severity="error")
939 return
940 if not args:
941 self._open_crawl_dialog()
942 return
943 parts = args.split()
944 url = parts[0]
945 if not is_url(url):
946 url = f"https://{url}"
947 try:
948 require_valid_crawl_url(url)
949 except ValueError as exc:
950 self.notify(str(exc), severity="error")
951 return
952 depth, max_pages, include_subdomains, render_mode = self._parse_crawl_flags(parts[1:])
953 self._start_crawl(
954 url,
955 depth,
956 max_pages,
957 include_subdomains=include_subdomains,
958 render_mode=render_mode,
959 )
961 def _open_crawl_dialog(self) -> None:
962 """Push the crawl modal and handle its result."""
963 from lilbee.cli.tui.widgets.crawl_dialog import CrawlDialog, CrawlParams
965 def _on_result(result: CrawlParams | None) -> None:
966 if result is not None:
967 self._start_crawl(
968 result.url, result.depth, result.max_pages, render_mode=result.render_mode
969 )
971 self.app.push_screen(CrawlDialog(), callback=_on_result)
973 def _start_crawl(
974 self,
975 url: str,
976 depth: int | None,
977 max_pages: int | None,
978 *,
979 include_subdomains: bool = False,
980 render_mode: CrawlRenderMode | None = None,
981 ) -> None:
982 """Enqueue a crawl task and run it in the background.
984 Bootstrap Chromium first via the controller helper, but only for a
985 browser-mode crawl. HTTP mode needs no browser, so the SETUP task is
986 skipped and the crawl starts immediately. An explicit ``render_mode``
987 (from the dialog checkbox or ``--render``) is persisted so the choice
988 sticks for the next crawl.
989 """
990 from lilbee.cli.tui.task_queue import TaskType
992 mode = render_mode if render_mode is not None else cfg.crawl_render_mode
993 if render_mode is not None and render_mode is not cfg.crawl_render_mode:
994 self._persist_crawl_render_mode(render_mode)
996 def _kick_off_crawl() -> None:
997 self._task_bar.start_task(
998 msg.TASK_NAME_CRAWL.format(url=url),
999 TaskType.CRAWL,
1000 lambda reporter: self._do_crawl(
1001 url,
1002 depth,
1003 max_pages,
1004 reporter,
1005 include_subdomains=include_subdomains,
1006 render_mode=mode,
1007 ),
1008 on_success=lambda: call_from_thread(self, self._run_sync),
1009 )
1011 self.notify(msg.CMD_CRAWL_STARTED.format(url=url))
1012 if mode is CrawlRenderMode.BROWSER:
1013 self._task_bar.ensure_chromium(_kick_off_crawl)
1014 else:
1015 _kick_off_crawl()
1017 def _persist_crawl_render_mode(self, render_mode: CrawlRenderMode) -> None:
1018 """Persist the chosen render mode so the dialog checkbox stays sticky."""
1019 from lilbee.app.settings import apply_settings_update
1021 try:
1022 apply_settings_update({"crawl_render_mode": render_mode.value})
1023 except (ValueError, OSError) as exc:
1024 log.warning("Could not persist crawl_render_mode: %s", exc)
1026 @staticmethod
1027 def _parse_crawl_flags(
1028 tokens: list[str],
1029 ) -> tuple[int | None, int | None, bool, CrawlRenderMode | None]:
1030 """Extract --depth, --max-pages, --include-subdomains, --render from tokens.
1032 Numeric flags return None when absent so the caller inherits
1033 crawl_and_save's unbounded-by-default semantics. The boolean
1034 ``--include-subdomains`` flag defaults to False (exact-host scope).
1035 ``--render http|browser`` returns None when absent so the caller
1036 inherits ``cfg.crawl_render_mode``; an unrecognized value is ignored.
1037 """
1038 flag_map = {_CRAWL_FLAG_DEPTH: "depth", _CRAWL_FLAG_MAX_PAGES: "max_pages"}
1039 parsed: dict[str, int | None] = {"depth": None, "max_pages": None}
1040 include_subdomains = False
1041 render_mode: CrawlRenderMode | None = None
1042 i = 0
1043 while i < len(tokens):
1044 if tokens[i] == _CRAWL_FLAG_INCLUDE_SUBDOMAINS:
1045 include_subdomains = True
1046 i += 1
1047 continue
1048 if tokens[i] == _CRAWL_FLAG_RENDER and i + 1 < len(tokens):
1049 with contextlib.suppress(ValueError):
1050 render_mode = CrawlRenderMode(tokens[i + 1])
1051 i += 2
1052 continue
1053 key = flag_map.get(tokens[i])
1054 if key and i + 1 < len(tokens):
1055 with contextlib.suppress(ValueError):
1056 parsed[key] = int(tokens[i + 1])
1057 i += 2
1058 else:
1059 i += 1
1060 return parsed["depth"], parsed["max_pages"], include_subdomains, render_mode
1062 def _do_crawl(
1063 self,
1064 url: str,
1065 depth: int | None,
1066 max_pages: int | None,
1067 reporter: ProgressReporter,
1068 *,
1069 include_subdomains: bool = False,
1070 render_mode: CrawlRenderMode | None = None,
1071 ) -> None:
1072 """Crawl body. Runs on worker thread; reporter handles progress + cancel."""
1073 from lilbee.crawler import crawl_and_save
1074 from lilbee.runtime.progress import CrawlPageEvent, CrawlPageFailedEvent, SetupProgressEvent
1076 reporter.update(0, msg.CMD_CRAWL_STARTED.format(url=url))
1077 failures: list[str] = []
1079 def on_progress(event_type: EventType, data: ProgressEvent) -> None:
1080 if event_type == EventType.SETUP_START:
1081 reporter.update(0, msg.SETUP_CHROMIUM_NAME)
1082 elif event_type == EventType.SETUP_PROGRESS and isinstance(data, SetupProgressEvent):
1083 if data.total_bytes:
1084 pct = int(data.downloaded_bytes * 100 / data.total_bytes)
1085 detail = msg.SETUP_CHROMIUM_DETAIL.format(
1086 done=data.downloaded_bytes // (1024 * 1024),
1087 total=data.total_bytes // (1024 * 1024),
1088 )
1089 else:
1090 pct = 0
1091 detail = msg.SETUP_CHROMIUM_DETAIL_UNKNOWN.format(
1092 done=data.downloaded_bytes // (1024 * 1024),
1093 )
1094 reporter.update(pct, detail)
1095 elif event_type == EventType.CRAWL_PAGE and isinstance(data, CrawlPageEvent):
1096 # Discovery hasn't resolved a sitemap yet (data.total <= 0):
1097 # show the indeterminate spinner with a count, not a parked
1098 # 50% bar that looks frozen. Switch to a determinate bar as
1099 # soon as the total is known.
1100 if data.total > 0:
1101 pct = int(data.current * 100 / data.total)
1102 reporter.update(
1103 pct,
1104 msg.CMD_CRAWL_PAGE.format(
1105 current=data.current, total=data.total, url=data.url
1106 ),
1107 indeterminate=False,
1108 )
1109 else: # pragma: no cover - live crawl without sitemap
1110 reporter.update(
1111 0,
1112 msg.CMD_CRAWL_PAGE_INDETERMINATE.format(current=data.current, url=data.url),
1113 indeterminate=True,
1114 )
1115 elif event_type == EventType.CRAWL_PAGE_FAILED and isinstance(
1116 data, CrawlPageFailedEvent
1117 ):
1118 failures.append(data.reason)
1120 paths = asyncio_loop.run(
1121 crawl_and_save(
1122 url,
1123 depth=depth,
1124 max_pages=max_pages,
1125 on_progress=on_progress,
1126 quiet=True,
1127 include_subdomains=include_subdomains,
1128 render_mode=render_mode,
1129 )
1130 )
1131 call_from_thread(self, self.notify, msg.CMD_CRAWL_SUCCESS.format(count=len(paths), url=url))
1132 if failures:
1133 call_from_thread(
1134 self,
1135 self.notify,
1136 msg.CMD_CRAWL_PAGES_FAILED.format(count=len(failures), reason=failures[-1]),
1137 severity="warning",
1138 )
1140 def _cmd_catalog(self, _args: str) -> None:
1141 # switch_view already installs and navigates to the managed Catalog view;
1142 # a push_screen on top would stack a second, orphaned CatalogScreen.
1143 self.app.switch_view(msg.CATALOG_VIEW)
1145 def _cmd_prune_ignored(self, args: str) -> None:
1146 """Sync with pruning on, dropping indexed documents the patterns now exclude."""
1147 del args
1148 self._run_sync(prune_ignored=True)
1150 def _cmd_delete(self, args: str) -> None:
1151 """Run /delete in a worker so the chat screen stays interactive."""
1152 self._cmd_delete_worker(args.strip())
1154 @work(thread=True, name="chat_cmd_delete", exit_on_error=False)
1155 def _cmd_delete_worker(self, name: str) -> None:
1156 """Validate and execute /delete off the UI thread; notify back via dispatch."""
1157 try:
1158 sources = get_services().store.get_sources()
1159 except Exception:
1160 log.debug("Failed to list documents for /delete", exc_info=True)
1161 call_from_thread(self, self.notify, msg.CMD_DELETE_READ_FAILED, severity="error")
1162 return
1164 known = set(removable_names([s.get("filename", s.get("source", "?")) for s in sources]))
1165 if not known:
1166 call_from_thread(self, self.notify, msg.CMD_DELETE_NO_DOCS, severity="warning")
1167 return
1169 if not name:
1170 usage = msg.CMD_DELETE_USAGE.format(names=", ".join(sorted(known)))
1171 call_from_thread(self, self.notify, usage)
1172 return
1174 if name not in known:
1175 message = msg.CMD_DELETE_NOT_FOUND.format(name=name)
1176 suggestion = _closest_source(name, known)
1177 if suggestion is not None:
1178 message = f"{message}. {msg.CMD_DELETE_SUGGESTION.format(name=suggestion)}"
1179 call_from_thread(self, self.notify, message, severity="error")
1180 return
1182 remove_documents_durably([name])
1183 call_from_thread(self, self.notify, msg.CMD_DELETE_SUCCESS.format(name=name))
1185 def _cmd_export(self, args: str) -> None:
1186 """Enqueue /export as a task so progress shows in the task bar."""
1187 path = args.strip()
1188 if not path:
1189 self.notify(msg.CMD_EXPORT_USAGE, severity="warning")
1190 return
1191 from lilbee.cli.tui.task_queue import TaskType
1193 def _target(reporter: ProgressReporter) -> None:
1194 self._do_export(path, reporter)
1196 name = msg.TASK_NAME_EXPORT.format(file=Path(path).name)
1197 self._task_bar.start_task(name, TaskType.EXPORT, _target, indeterminate=True)
1199 def _do_export(self, raw_path: str, reporter: ProgressReporter) -> None:
1200 """Export body. Runs on the task worker thread."""
1201 from lilbee.app.dataset import DatasetError, export_to_path
1203 output = Path(raw_path).expanduser()
1204 reporter.update(0, msg.EXPORT_STATUS_RUNNING, indeterminate=True)
1205 try:
1206 summary = export_to_path(output, "", None)
1207 except DatasetError as exc:
1208 call_from_thread(self, self.notify, str(exc), severity="error")
1209 raise RuntimeError(str(exc)) from exc
1210 call_from_thread(
1211 self,
1212 self.notify,
1213 msg.CMD_EXPORT_SUCCESS.format(pages=summary.pages, output=output),
1214 )
1216 def _cmd_import(self, args: str) -> None:
1217 """Enqueue /import as a task so re-embedding progress shows in the task bar."""
1218 path = args.strip()
1219 if not path:
1220 self.notify(msg.CMD_IMPORT_USAGE, severity="warning")
1221 return
1222 if self._sync_active:
1223 self.notify(msg.SYNC_ALREADY_ACTIVE, severity="warning")
1224 return
1225 from lilbee.cli.tui.task_queue import TaskType
1227 self._sync_active = True
1229 def _target(reporter: ProgressReporter) -> None:
1230 try:
1231 self._do_import(path, reporter)
1232 finally:
1233 self._sync_active = False
1234 self._task_bar.start_detect_pending()
1236 name = msg.TASK_NAME_IMPORT.format(file=Path(path).name)
1237 self._task_bar.start_task(name, TaskType.IMPORT, _target)
1239 def _do_import(self, raw_path: str, reporter: ProgressReporter) -> None:
1240 """Import body. Runs on the task worker thread."""
1241 from lilbee.app.dataset import DatasetError, import_from_path
1243 reporter.update(0, msg.IMPORT_STATUS_LOADING, indeterminate=True)
1244 try:
1245 summary = asyncio_loop.run(
1246 import_from_path(
1247 Path(raw_path).expanduser(),
1248 "",
1249 on_progress=build_import_progress_callback(reporter),
1250 )
1251 )
1252 except DatasetError as exc:
1253 call_from_thread(self, self.notify, str(exc), severity="error")
1254 raise RuntimeError(str(exc)) from exc
1255 call_from_thread(
1256 self,
1257 self.notify,
1258 msg.CMD_IMPORT_SUCCESS.format(
1259 sources=len(summary.sources), pages=summary.pages, chunks=summary.chunks
1260 ),
1261 )
1263 def _cmd_help(self, _args: str) -> None:
1264 self.action_show_command_catalog()
1266 def action_show_command_catalog(self) -> None:
1267 """Push the slash-command catalog modal; selected name is inserted into the input."""
1268 self.app.push_screen(SlashCommandCatalog(), self._on_catalog_pick)
1270 def insert_slash_command(self, name: str) -> None:
1271 """Drop ``name + ' '`` into the chat input and focus it for argument entry."""
1272 self._enter_insert_mode()
1273 inp = self._chat_input
1274 inp.value = f"{name} "
1275 inp.action_end()
1277 def _on_catalog_pick(self, name: str | None) -> None:
1278 if name is None:
1279 return
1280 self.insert_slash_command(name)
1282 def _cmd_login(self, args: str) -> None:
1283 token = args.strip()
1284 if not token:
1285 import webbrowser
1287 webbrowser.open("https://huggingface.co/settings/tokens")
1288 self.notify(msg.CHAT_LOGIN_PROMPT)
1289 return
1290 self._run_hf_login(token)
1292 @work(thread=True)
1293 def _run_hf_login(self, token: str) -> None:
1294 try:
1295 from huggingface_hub import login
1297 login(token=token, add_to_git_credential=False)
1298 call_from_thread(self, self.notify, msg.CHAT_LOGGED_IN)
1299 except Exception as exc:
1300 log.warning("HuggingFace login failed", exc_info=True)
1301 call_from_thread(
1302 self, self.notify, msg.CHAT_LOGIN_FAILED.format(error=exc), severity="error"
1303 )
1305 def _cmd_model(self, args: str) -> None:
1306 if args:
1307 from lilbee.catalog.formatting import display_label_for_ref
1309 apply_active_model(self.app, "chat_model", args)
1310 self.app.title = msg.app_title(cfg.chat_model)
1311 self.notify(
1312 msg.CMD_MODEL_SET.format(name=display_label_for_ref(cfg.chat_model)),
1313 )
1314 self.apply_model_change()
1315 self.refresh_model_bar()
1316 else:
1317 from lilbee.cli.tui.screens.catalog import CatalogScreen
1319 self.app.push_screen(CatalogScreen())
1321 def _cmd_quit(self, _args: str) -> None:
1322 self.app.exit()
1324 def _cmd_remove(self, args: str) -> None:
1325 name = args.strip()
1326 if not name:
1327 self.notify(msg.CMD_REMOVE_USAGE, severity="warning")
1328 return
1329 self._run_remove_model(name)
1331 @work(thread=True)
1332 def _run_remove_model(self, name: str) -> None:
1333 mgr = get_services().model_manager
1334 try:
1335 # The remove call is the only check: a pre-check would answer
1336 # "installed?" a second way and disagree with `lilbee model rm`.
1337 if mgr.remove(name):
1338 call_from_thread(self, self.notify, msg.CMD_REMOVE_SUCCESS.format(name=name))
1339 else:
1340 call_from_thread(
1341 self, self.notify, msg.CMD_REMOVE_NOT_FOUND.format(name=name), severity="error"
1342 )
1343 except Exception:
1344 log.warning("Remove failed for %s", name, exc_info=True)
1345 call_from_thread(
1346 self, self.notify, msg.CMD_REMOVE_FAILED.format(name=name), severity="error"
1347 )
1349 def _cmd_rebuild(self, _args: str) -> None:
1350 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
1352 def _on_confirm(confirmed: bool | None) -> None:
1353 if not confirmed:
1354 return
1355 self._run_sync(force_rebuild=True)
1357 self.app.push_screen(
1358 ConfirmDialog(msg.CMD_REBUILD_CONFIRM_TITLE, msg.CMD_REBUILD_CONFIRM_MESSAGE),
1359 _on_confirm,
1360 )
1362 def _cmd_reset(self, args: str) -> None:
1363 self.request_reset()
1365 def request_reset(self) -> None:
1366 """Public entry for the confirm-then-wipe flow (shared by /reset and the
1367 command palette), so callers don't reach into a private slash handler."""
1368 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
1370 def _on_confirm(confirmed: bool | None) -> None:
1371 if not confirmed:
1372 return
1373 from lilbee.app.reset import perform_reset
1375 try:
1376 result = perform_reset()
1377 except ResetRefusedError as exc:
1378 self.notify(str(exc), severity="warning")
1379 return
1380 except Exception as exc:
1381 log.warning("Reset failed", exc_info=True)
1382 self.notify(msg.CMD_RESET_FAILED.format(error=exc), severity="error")
1383 return
1385 # Reopen LanceDB against the now-empty data dir; keep providers loaded.
1386 reset_store()
1388 if result.skipped:
1389 self.notify(
1390 msg.CMD_RESET_PARTIAL.format(skipped=len(result.skipped)),
1391 severity="warning",
1392 )
1393 else:
1394 self.notify(msg.CMD_RESET_SUCCESS)
1396 self.app.push_screen(
1397 ConfirmDialog(msg.CMD_RESET_CONFIRM_TITLE, msg.CMD_RESET_CONFIRM_MESSAGE),
1398 _on_confirm,
1399 )
1401 def _cmd_set(self, args: str) -> None:
1402 if not args:
1403 return
1404 parts = args.split(None, 1)
1405 key = parts[0]
1406 value = parts[1] if len(parts) > 1 else ""
1408 if key not in SETTINGS_MAP:
1409 self.notify(msg.CMD_SET_UNKNOWN.format(key=key), severity="warning")
1410 return
1412 defn = SETTINGS_MAP[key]
1413 if not defn.writable and key not in CLEARABLE_MODEL_FIELDS:
1414 self.notify(msg.CMD_SET_READONLY.format(key=key), severity="warning")
1415 return
1416 clears = defn.nullable and value.lower() in ("none", "null", "")
1417 try:
1418 parsed: object
1419 if key in CLEARABLE_MODEL_FIELDS:
1420 parsed = "" if clears else value
1421 elif defn.type is bool:
1422 parsed = value.lower() in ("true", "1", "yes", "on")
1423 elif clears:
1424 parsed = None
1425 else:
1426 if defn.choices and value not in defn.choices:
1427 self.notify(
1428 msg.CMD_SET_CHOICES.format(key=key, choices=", ".join(defn.choices)),
1429 severity="error",
1430 )
1431 return
1432 try:
1433 parsed = defn.type(value)
1434 except (ValueError, TypeError):
1435 self.notify(
1436 msg.CMD_SET_TYPE_HINT.format(key=key, kind=_setting_type_hint(defn.type)),
1437 severity="error",
1438 )
1439 return
1440 # Route through set_setting so settings_changed_signal subscribers
1441 # (model bar, scope chip, status bar) refresh. The boundary's
1442 # _invalidate_caches now handles llm_provider service reset.
1443 self.app.set_setting(key, parsed)
1444 shown = msg.MASKED_VALUE if defn.secret and parsed else parsed
1445 self.notify(msg.CMD_SET_SUCCESS.format(key=key, value=shown))
1446 except (ValueError, TypeError) as exc:
1447 self.notify(msg.CMD_SET_INVALID.format(key=key, error=exc), severity="error")
1449 def _cmd_settings(self, _args: str) -> None:
1450 self.app.switch_view("Settings")
1452 def _cmd_remember(self, args: str) -> None:
1453 """Run /remember in a worker so embedding the text never blocks the UI."""
1454 self._cmd_remember_worker(args)
1456 @work(thread=True, name="chat_cmd_remember", exit_on_error=False)
1457 def _cmd_remember_worker(self, raw: str) -> None:
1458 """Store the memory off the UI thread; notify the outcome back on it."""
1459 outcome = remember_from_input(raw)
1460 call_from_thread(self, self.notify, outcome.message, severity=outcome.severity)
1462 def _cmd_memories(self, _args: str) -> None:
1463 from lilbee.cli.tui.screens.memories import MemoriesScreen
1465 self.app.push_screen(MemoriesScreen())
1467 def _cmd_status(self, _args: str) -> None:
1468 self.app.switch_view("Status")
1470 def _cmd_theme(self, args: str) -> None:
1471 if not args:
1472 # Land in the prompt with the dropdown listing every theme.
1473 self.insert_slash_command("/theme")
1474 return
1475 if args not in DARK_THEMES:
1476 self.notify(
1477 msg.CMD_THEME_UNKNOWN.format(name=args, names=", ".join(DARK_THEMES)),
1478 severity="warning",
1479 )
1480 return
1481 self.app.set_theme(args)
1482 self.notify(msg.THEME_SET.format(name=args))
1484 def _cmd_version(self, _args: str) -> None:
1485 self.notify(msg.CHAT_VERSION.format(version=get_version()))
1487 def _cmd_wiki(self, _args: str) -> None:
1488 if not cfg.wiki:
1489 self.notify(msg.CMD_WIKI_DISABLED, severity="warning")
1490 return
1491 self.app.switch_view("Wiki")
1493 def _cmd_sessions(self, _args: str) -> None:
1494 self.app.call_later(self.app.action_toggle_sessions)
1496 def _read_active_session(self, no_session: str, gone: str) -> Session | None:
1497 """The active session as the store holds it, or None after telling the user why not."""
1498 if not cfg.sessions_enabled:
1499 self.app.notify_sessions_disabled()
1500 return None
1501 if self._session_id is None:
1502 self.notify(no_session, severity="warning")
1503 return None
1504 try:
1505 return get_services().session_store.get(self._session_id)
1506 except SessionNotFoundError:
1507 self.notify(gone, severity="warning")
1508 return None
1510 def _cmd_fork(self, _args: str) -> None:
1511 """Open the fork picker over the answers of the current session as the store holds it."""
1512 source = self._read_active_session(msg.FORK_NO_SESSION, msg.FORK_SESSION_GONE)
1513 if source is None:
1514 return
1515 points = fork_points(source.messages)
1516 if not points:
1517 self.notify(msg.FORK_NO_ANSWER, severity="warning")
1518 return
1519 self.app.push_screen(ForkPicker(points), partial(self._on_fork_picked, source))
1521 def _cmd_export_chat(self, args: str) -> None:
1522 """Write the current session as markdown to *args*, or to the working directory."""
1523 session = self._read_active_session(
1524 msg.EXPORT_CHAT_NO_SESSION, msg.EXPORT_CHAT_SESSION_GONE
1525 )
1526 if session is None:
1527 return
1528 try:
1529 path = write_session_markdown(session, args.strip() or ".")
1530 except OSError as exc:
1531 self.notify(msg.EXPORT_CHAT_FAILED.format(error=exc), severity="error")
1532 return
1533 self.notify(msg.EXPORT_CHAT_DONE.format(path=path))
1535 def _on_fork_picked(self, source: Session, message_count: int | None) -> None:
1536 """Fork *source* after the picked answer and switch to the fork with an empty input."""
1537 if message_count is None:
1538 return
1539 try:
1540 fork_id = get_services().session_store.fork(
1541 source.meta.id, message_count=message_count, origin=SessionOrigin.TUI
1542 )
1543 except SessionNotFoundError:
1544 self.notify(msg.FORK_SESSION_GONE, severity="warning")
1545 return
1546 except SessionOwnershipError as exc:
1547 self.notify(msg.FORK_FAILED.format(error=exc), severity="warning")
1548 return
1549 fork = self._load_session(fork_id)
1550 self.notify(msg.FORK_DONE.format(title=fork.meta.title))
1551 self._set_input("")
1552 self._enter_insert_mode()
1554 def _send_message(self, text: str) -> None:
1555 """Send a user message and stream the response, unless a turn is live."""
1556 from textual.css.query import NoMatches
1558 if self.streaming:
1559 self._notify_busy()
1560 return
1561 log = self._chat_log
1562 with contextlib.suppress(NoMatches):
1563 log.query_one("#chat-welcome", ChatWelcome).remove()
1564 question = UserMessage(text)
1565 log.mount(question)
1566 self._active_question = question
1568 # The assistant bubble owns its own ThinkingHeader animator until
1569 # the first reasoning or content token swaps it out.
1570 assistant_msg = AssistantMessage()
1571 log.mount(assistant_msg)
1572 # A fresh turn always follows its own answer, even if the user had
1573 # scrolled up during the previous response and released the anchor.
1574 log.anchor()
1576 with self._history_lock:
1577 self._history.append({"role": "user", "content": text})
1578 self._persist_user_turn(text)
1579 turn = _Turn(text, assistant_msg, self._conversation_generation, self._session_id)
1580 self._turn = turn
1581 self.streaming = True
1582 self._stream_response(turn, self._current_chunk_type())
1584 def _current_scope_value(self) -> str:
1585 """The ScopeChip's selection, or "both" when the chip isn't mounted."""
1586 from textual.css.query import NoMatches
1588 from lilbee.cli.tui.widgets.scope_chip import ScopeChip
1590 try:
1591 chip = self.query_one("#scope-chip", ScopeChip)
1592 except NoMatches:
1593 return SearchScope.BOTH.value
1594 return chip.scope
1596 def _current_chunk_type(self) -> ChunkType | None:
1597 """Translate the ScopeChip selection into a ``chunk_type`` arg.
1599 Returns ``None`` for "both" (no filter) and the raw/wiki ``ChunkType``
1600 otherwise.
1601 """
1602 return scope_to_chunk_type(self._current_scope_value())
1604 def _open_session(self, store: SessionStore, first_text: str) -> str:
1605 """Create the active session, auto-title it, and return its id."""
1606 session_id = store.create(model_ref=cfg.chat_model, scope=self._current_scope_value())
1607 store.set_title(session_id, derive_title(first_text), TitleSource.AUTO)
1608 self._session_id = session_id
1609 return session_id
1611 def _persist_user_turn(self, text: str) -> None:
1612 """Open a session on the first turn (auto-titled), then append the message."""
1613 if not cfg.sessions_enabled:
1614 # Sessions turned off: the conversation stays live in memory but is
1615 # never written to disk. _session_id stays None, so the assistant
1616 # turn's persist is a no-op too.
1617 return
1618 store = get_services().session_store
1619 session_id = self._session_id or self._open_session(store, text)
1620 message = SessionMessage(role=MessageRole.USER, content=text)
1621 try:
1622 store.add_message(session_id, message, surface=SessionOrigin.TUI)
1623 except SessionNotFoundError:
1624 # The active session was deleted mid-chat (e.g. from the drawer);
1625 # open a fresh one so auto-save keeps working instead of crashing.
1626 store.add_message(self._open_session(store, text), message, surface=SessionOrigin.TUI)
1628 def _save_assistant_turn(
1629 self, session_id: str | None, content: str, sources: list[str]
1630 ) -> None:
1631 """Persist the assistant turn, telling the user when the save fails. Worker thread."""
1632 try:
1633 self._persist_assistant_turn(session_id, content, sources)
1634 except (OSError, SessionOwnershipError) as exc:
1635 log.warning("Could not save the assistant turn", exc_info=True)
1636 call_from_thread(
1637 self, self.notify, msg.SESSIONS_SAVE_FAILED.format(error=exc), severity="error"
1638 )
1640 def _persist_assistant_turn(
1641 self, session_id: str | None, content: str, sources: list[str]
1642 ) -> None:
1643 """Append the assistant turn to *session_id*, the session its turn started in."""
1644 if session_id is None or not cfg.sessions_enabled:
1645 # Sessions switched off mid-conversation: the id outlives the
1646 # setting, so the toggle has to be re-checked here rather than
1647 # relying on _persist_user_turn having left the id unset.
1648 return
1649 # A concurrent delete of the active session must not crash the worker.
1650 with contextlib.suppress(SessionNotFoundError):
1651 get_services().session_store.add_message(
1652 session_id,
1653 SessionMessage(role=MessageRole.ASSISTANT, content=content, sources=tuple(sources)),
1654 surface=SessionOrigin.TUI,
1655 )
1657 def resume_session(self, session_id: str) -> None:
1658 """Load a saved session into the chat view, make it the active one, and focus the prompt."""
1659 session = self._load_session(session_id)
1660 self.focus_prompt()
1661 self.notify(msg.SESSIONS_RESUMED.format(title=session.meta.title))
1663 def _load_session(self, session_id: str) -> Session:
1664 """Replace the conversation with a saved session's transcript and summary."""
1665 session = get_services().session_store.get(session_id)
1666 self._reset_conversation()
1667 self._session_id = session_id
1668 for message in session.messages:
1669 self._render_restored_message(message)
1670 # Load the whole transcript and the summary it was compacted with. What
1671 # does not fit is folded into the summary by _compact_history on the next
1672 # turn, off the UI thread; windowing it away here would silently lose the
1673 # turns between the stored summary and the window, which is precisely
1674 # what a resumed conversation must not do.
1675 loaded: list[ChatMessage] = [
1676 {"role": message.role.value, "content": message.content} for message in session.messages
1677 ]
1678 with self._history_lock:
1679 self._history = loaded
1680 self._summary = session.summary
1681 self._restore_session_model(session.meta.model_ref)
1682 self._refresh_context_usage()
1683 self._chat_log.scroll_end(animate=False)
1684 return session
1686 def _restore_session_model(self, model_ref: str) -> None:
1687 """Switch to the session's chat model if it is still installed.
1689 A conversation records the model it used, but that model may have been
1690 deleted since. Restoring a missing ref would be rejected by the model
1691 boundary with a scary error, so only switch when the model is installed;
1692 otherwise keep the current model and say the original is gone.
1693 """
1694 if not model_ref or model_ref == cfg.chat_model:
1695 return
1696 if get_services().registry.is_installed(model_ref):
1697 apply_active_model(self.app, "chat_model", model_ref)
1698 else:
1699 self.notify(
1700 msg.SESSIONS_MODEL_UNAVAILABLE.format(model=model_ref, current=cfg.chat_model),
1701 severity="warning",
1702 )
1704 @property
1705 def session_id(self) -> str | None:
1706 """The saved session this conversation persists to, or None before the first turn."""
1707 return self._session_id
1709 def start_new_conversation(self) -> None:
1710 """Clear the conversation, open a fresh session on the next turn, and focus the prompt."""
1711 self._reset_conversation()
1712 self.focus_prompt()
1713 self.notify(msg.SESSIONS_NEW)
1715 def _render_restored_message(self, message: SessionMessage) -> None:
1716 """Mount a completed message widget for a resumed turn."""
1717 log = self._chat_log
1718 if message.role == MessageRole.USER:
1719 log.mount(UserMessage(message.content))
1720 return
1721 # Constructed complete, not appended-to after mounting: mount() is async,
1722 # so append_content/finish would both no-op against a content widget that
1723 # compose has not built yet, and the answer would render empty.
1724 log.mount(AssistantMessage(content=message.content, sources=list(message.sources)))
1726 @work(thread=True, group=_TURN_WORKER_GROUP)
1727 def _stream_response(self, turn: _Turn, chunk_type: ChunkType | None) -> None:
1728 """Run *turn* on a background thread and end it, whatever happens."""
1729 try:
1730 self._do_stream_response(turn, chunk_type)
1731 finally:
1732 post_from_thread(self, _TurnEnded(turn))
1734 def _do_stream_response(self, turn: _Turn, chunk_type: ChunkType | None) -> None:
1735 """Stream the answer to *turn*'s question and save it. Worker thread."""
1736 response_parts: list[str] = []
1737 stream: Any = None
1738 try:
1739 if not self._await_chat_engine(turn) or self._turn_stopped(turn):
1740 return
1741 self._compact_history(turn.session_id, turn.generation)
1742 if self._turn_stopped(turn):
1743 return
1744 with self._history_lock:
1745 # [:-1] drops the question, which ask_stream takes separately.
1746 recent = self._history[:-1]
1747 summary = self._summary
1748 history_snapshot = prompt_history(recent, summary, max_tokens=self._history_budget())
1749 stream = get_services().searcher.ask_stream(
1750 turn.question, history=history_snapshot, chunk_type=chunk_type
1751 )
1752 self._consume_stream(stream, turn, response_parts)
1753 except EmbeddingModelMismatchError as exc:
1754 with contextlib.suppress(Exception):
1755 call_from_thread(self, self._on_embedding_mismatch, exc, turn.question, turn.widget)
1756 except Exception as exc:
1757 log.debug("Stream error", exc_info=True)
1758 # A stop severs the transport, which surfaces here as a stream error;
1759 # the turn's end already says it was cancelled.
1760 if not self._turn_stopped(turn):
1761 with contextlib.suppress(Exception):
1762 call_from_thread(self, turn.widget.append_content, _stream_error_text(exc))
1763 finally:
1764 close_stream(stream)
1765 turn.answer = "".join(response_parts)
1766 self._save_turn(turn)
1768 @staticmethod
1769 def _turn_stopped(turn: _Turn) -> bool:
1770 """Whether *turn* was asked to stop, or Textual is tearing its worker down."""
1771 if turn.stop.is_set():
1772 return True
1773 try:
1774 return _get_worker().is_cancelled
1775 except NoActiveWorker:
1776 return False
1778 def _await_chat_engine(self, turn: _Turn) -> bool:
1779 """Hold the stream until the engine can serve, painting the load into *turn*'s bubble.
1781 The default lifecycle loads the engine on demand, so the first prompt of
1782 a session usually lands here: the answer bubble's thinking row carries the
1783 live load phase instead of the input locking up. Worker thread. Returns
1784 False once the turn was stopped or the load failed, with any failure
1785 already rendered into the bubble.
1786 """
1787 from lilbee.app.placement import (
1788 chat_engine_ready,
1789 chat_warm_error,
1790 request_engine_warm,
1791 wait_chat_ready,
1792 )
1794 # Build the container if nothing holds it (a settings change resets it);
1795 # readiness is probed via peek_services, which never builds, so without
1796 # this a prompt sent into the gap would report a dead engine instead of
1797 # lazily rebuilding the way ask_stream always has.
1798 get_services()
1799 if chat_engine_ready():
1800 return True
1801 # A failed boot warm leaves nothing in flight; this restarts the engine
1802 # so the prompt waits out a fresh load instead of bouncing.
1803 request_engine_warm()
1804 self._show_warm_tip_once()
1805 widget = turn.widget
1807 def _paint(snapshot: WarmProgress) -> None:
1808 with contextlib.suppress(Exception):
1809 call_from_thread(self, widget.set_thinking_status, _engine_status_text(snapshot))
1811 # Label the wait before the chat warm stamps its first phase: another
1812 # role loading first (embed on a cold start) leaves the tracker silent
1813 # for many seconds, and a bare scanner reads as a hang.
1814 with contextlib.suppress(Exception):
1815 call_from_thread(self, widget.set_thinking_status, msg.ENGINE_WARMING)
1816 if wait_chat_ready(on_progress=_paint, should_abort=lambda: self._turn_stopped(turn)):
1817 with contextlib.suppress(Exception):
1818 call_from_thread(self, widget.set_thinking_status, "")
1819 return True
1820 if self._turn_stopped(turn):
1821 return False
1822 error = chat_warm_error()
1823 text = (
1824 f"{msg.ENGINE_LOAD_FAILED.format(error=error)}\n{msg.ENGINE_FAILED_HINT}"
1825 if error is not None
1826 else msg.ENGINE_NOT_READY
1827 )
1828 with contextlib.suppress(Exception):
1829 call_from_thread(self, widget.append_content, text)
1830 return False
1832 def _show_warm_tip_once(self) -> None:
1833 """Toast the keep-warm tip on the session's first cold-engine wait. Worker thread."""
1834 if cfg.keep_engine_warm or self._warm_tip_shown:
1835 return
1836 self._warm_tip_shown = True
1837 with contextlib.suppress(Exception):
1838 call_from_thread(self, self.notify, msg.ENGINE_WARM_TIP, timeout=8)
1840 def _maybe_extract_memories(self, question: str, answer: str) -> None:
1841 """Spawn auto-extraction for the finished turn, when enabled and idle.
1843 Runs on the main thread (scheduled from the stream worker). Skips while
1844 indexing so the extraction's embed call never contends with a sync.
1845 """
1846 from lilbee.app.memory import auto_extract_enabled
1848 if not answer or not auto_extract_enabled() or self._indexing_active():
1849 return
1850 self._extract_memories_worker(question, answer)
1852 def _indexing_active(self) -> bool:
1853 """True while a sync/add/import/wiki task is running (embed worker is busy).
1855 Wiki counts: a build embeds citations and a draft accept re-chunks and
1856 re-indexes the page it publishes.
1857 """
1858 from lilbee.cli.tui.task_queue import TaskType
1860 busy = {
1861 TaskType.SYNC.value,
1862 TaskType.ADD.value,
1863 TaskType.IMPORT.value,
1864 TaskType.WIKI.value,
1865 }
1866 return any(task.task_type in busy for task in self._task_bar.queue.active_tasks)
1868 @work(thread=True, name="chat_memory_extract", exit_on_error=False)
1869 def _extract_memories_worker(self, question: str, answer: str) -> None:
1870 """Extract durable memories off the UI thread; notify how many landed."""
1871 from lilbee.app.memory import auto_extract
1873 stored = auto_extract(question, answer)
1874 if stored:
1875 call_from_thread(self, self.notify, msg.MEMORY_AUTO_EXTRACTED.format(count=len(stored)))
1877 def _on_embedding_mismatch(
1878 self, exc: EmbeddingModelMismatchError, question: str, widget: AssistantMessage
1879 ) -> None:
1880 """Offer to adopt the index's embedder (same dim) or explain the rebuild path."""
1881 if not exc.dims_match:
1882 widget.append_content(msg.EMBED_ADOPT_REBUILD_NOTICE.format(dim=exc.persisted_dim))
1883 return
1884 widget.append_content(msg.EMBED_ADOPT_NOTICE.format(model=exc.persisted_model))
1885 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
1887 self.app.push_screen(
1888 ConfirmDialog(
1889 msg.EMBED_ADOPT_CONFIRM_TITLE,
1890 msg.EMBED_ADOPT_CONFIRM_MESSAGE.format(model=exc.persisted_model),
1891 ),
1892 lambda ok: self._on_adopt_confirm(ok, exc.persisted_model, question),
1893 )
1895 def _on_adopt_confirm(self, confirmed: bool | None, ref: str, question: str) -> None:
1896 """Run the adopt+retry in a worker thread, or report the cancellation."""
1897 if not confirmed:
1898 self.notify(msg.EMBED_ADOPT_CANCELLED)
1899 return
1900 self.notify(msg.EMBED_ADOPTING.format(model=ref))
1901 self._adopt_and_retry(ref, question)
1903 @work(thread=True)
1904 def _adopt_and_retry(self, ref: str, question: str) -> None:
1905 """Schedule the adopt+retry on a worker thread (pull may be slow)."""
1906 self._do_adopt_and_retry(ref, question)
1908 def _do_adopt_and_retry(self, ref: str, question: str) -> None:
1909 """Switch to embedder *ref* (downloading if needed), then re-ask. Worker thread."""
1910 from lilbee.app.models import adopt_embedder
1912 try:
1913 adopt_embedder(ref)
1914 except Exception as exc: # surfaced to the user, never silently swallowed
1915 log.debug("Embedder adopt failed", exc_info=True)
1916 call_from_thread(
1917 self, self.notify, msg.EMBED_ADOPT_FAILED.format(error=exc), severity="error"
1918 )
1919 return
1920 call_from_thread(self, self.notify, msg.EMBED_ADOPTED.format(model=ref))
1921 call_from_thread(self, self._send_message, question)
1923 def _consume_stream(self, stream: Any, turn: _Turn, response_parts: list[str]) -> None:
1924 """Pull tokens off *stream* into *turn*'s bubble, batching UI updates to ~50 ms windows."""
1925 widget = turn.widget
1926 reason_buf: list[str] = []
1927 content_buf: list[str] = []
1928 timings = _StreamTimings(last_flush=time.monotonic())
1930 def flush() -> None:
1931 if reason_buf:
1932 call_from_thread(self, widget.append_reasoning, "".join(reason_buf))
1933 reason_buf.clear()
1934 if content_buf:
1935 call_from_thread(self, widget.append_content, "".join(content_buf))
1936 content_buf.clear()
1938 for token in stream:
1939 if self._turn_stopped(turn):
1940 break
1941 try:
1942 if isinstance(token, RetrievalNotice):
1943 status = msg.SEARCHING_FOR.format(query=token.query)
1944 call_from_thread(self, widget.set_thinking_status, status)
1945 continue
1946 self._buffer_token(token, reason_buf, content_buf, response_parts)
1947 self._maybe_flush(flush, timings)
1948 except Exception:
1949 break # App shutting down (Ctrl-C) -- stop streaming
1950 with contextlib.suppress(Exception):
1951 flush()
1953 @staticmethod
1954 def _buffer_token(
1955 token: Any,
1956 reason_buf: list[str],
1957 content_buf: list[str],
1958 response_parts: list[str],
1959 ) -> None:
1960 """Append *token* to the right buffer; record response content for history."""
1961 if token.is_reasoning:
1962 reason_buf.append(token.content)
1963 elif token.content:
1964 response_parts.append(token.content)
1965 content_buf.append(token.content)
1967 def _maybe_flush(self, flush: Callable[[], None], timings: _StreamTimings) -> None:
1968 """Run *flush* on its interval. The chat log is anchored, so Textual
1969 keeps the answer's tail in view as it grows without a scroll of ours.
1970 """
1971 now = time.monotonic()
1972 if now - timings.last_flush >= _STREAM_FLUSH_INTERVAL:
1973 flush()
1974 timings.last_flush = now
1976 def _save_turn(self, turn: _Turn) -> None:
1977 """Add *turn*'s answer to the history and its session. Worker thread.
1979 The answer joins the history only while the turn's conversation is still
1980 on screen; it is saved to the turn's session either way.
1981 """
1982 if not turn.answer:
1983 return
1984 with self._history_lock:
1985 # A resume or /clear mid-answer replaced the conversation; the
1986 # answer belongs to the one that is gone.
1987 if turn.generation == self._conversation_generation:
1988 self._history.append({"role": "assistant", "content": turn.answer})
1989 # No trim here: the next turn compacts before it builds its prompt, so
1990 # trimming now would drop turns without folding them in.
1991 self._save_assistant_turn(turn.session_id, turn.answer, [])
1993 @on(_TurnEnded)
1994 def _on_turn_ended(self, event: _TurnEnded) -> None:
1995 """Drop the busy flag first, then finish the turn's bubble if it is on screen."""
1996 turn = event.turn
1997 self._turn = None
1998 self.stopping = False
1999 self.streaming = False
2000 self._maybe_extract_memories(turn.question, turn.answer)
2001 if turn.generation == self._conversation_generation:
2002 self._show_turn_end(turn)
2004 def _show_turn_end(self, turn: _Turn) -> None:
2005 """Finish *turn*'s bubble, its cancel note, the context chip and the search toast."""
2006 if turn.stop.is_set():
2007 turn.widget.append_content(msg.STREAM_CANCELLED)
2008 turn.widget.finish()
2009 if not turn.answer:
2010 return
2011 self._refresh_context_usage()
2012 if (
2013 cfg.chat_mode == ChatMode.SEARCH.value
2014 and self._embedding_ready()
2015 and SOURCES_BLOCK_MARKER not in turn.answer
2016 ):
2017 self._notify_no_results()
2019 def _notify_no_results(self) -> None:
2020 self.notify(msg.CHAT_MODE_SEARCH_NO_RESULTS, severity="warning")
2022 @staticmethod
2023 def _history_budget() -> int:
2024 """Token budget for everything this conversation carries into the prompt."""
2025 return history_budget(cfg.chat_n_ctx_target)
2027 def _compact_history(self, session_id: str | None, generation: int) -> None:
2028 """Fold turns that no longer fit into the rolling summary. Worker thread only.
2030 Runs before a prompt is built rather than after a turn lands, so the
2031 summary is always current with what is about to be sent, and a resumed
2032 conversation compacts what it cannot carry instead of dropping it.
2034 The summarizing model call is slow, so it happens without the lock held;
2035 only the known prefix is removed afterwards, which stays correct if the
2036 user sends another turn meanwhile. The result applies only while
2037 *generation* is still the conversation on screen, and the summary is
2038 saved to *session_id*, the session the turn started in.
2039 """
2040 with self._history_lock:
2041 history = list(self._history)
2042 summary = self._summary
2043 budget = self._history_budget()
2044 if not cfg.chat_compaction:
2045 # Default path, deliberately free: prune exactly to the limit, no
2046 # model call. The summary is charged against the same budget so a
2047 # session compacted on earlier hardware still carries its notes.
2048 reserved = sum(estimate_tokens(m) for m in summary_messages(summary))
2049 dropped = overflow(history, max_tokens=max(1, budget - reserved))
2050 if not dropped:
2051 return
2052 with self._history_lock:
2053 if generation != self._conversation_generation:
2054 return
2055 del self._history[: len(dropped)]
2056 call_from_thread(self, self._on_history_trimmed, len(dropped))
2057 return
2058 # Compaction on: fire early, clear deep (see COMPACT_TRIGGER_FRACTION).
2059 if not compaction_due(history, summary, max_tokens=budget):
2060 return
2061 dropped = foldable(history)
2062 if not dropped:
2063 # Nothing but the tail, and it alone fills the budget. Folding it
2064 # would summarize the very turn being answered; prompt_history windows
2065 # it instead.
2066 return
2067 # Condensing blocks this turn on a model call: seconds on a GPU, tens of
2068 # seconds on a CPU-only host. An unannounced pause that long is
2069 # indistinguishable from a hang, so say what is happening first.
2070 call_from_thread(self, self._set_compacting, True)
2071 try:
2072 result = get_services().searcher.summarize_history(dropped, summary)
2073 finally:
2074 call_from_thread(self, self._set_compacting, False)
2075 with self._history_lock:
2076 if generation != self._conversation_generation:
2077 # A resume or /clear replaced the conversation during the model
2078 # call; the fold belongs to the one that is gone.
2079 return
2080 del self._history[: len(dropped)]
2081 self._summary = result.summary
2082 if session_id and result.summary and cfg.sessions_enabled:
2083 # A summary for a session deleted mid-chat is not worth a crash; the
2084 # next user turn reopens one and re-summarizes from there. The
2085 # toggle is re-checked because the fold keeps working in memory
2086 # after sessions go off, but must not reach the disk.
2087 with contextlib.suppress(SessionNotFoundError):
2088 get_services().session_store.set_summary(session_id, result.summary)
2089 call_from_thread(self, self._on_history_compacted, result.condensed, result.stranded)
2091 def _set_compacting(self, compacting: bool) -> None:
2092 """Flip the chip into (or out of) its condensing state. Main thread only."""
2093 with contextlib.suppress(NoMatches):
2094 self.query_one("#context-chip", ContextChip).compacting = compacting
2096 def _refresh_context_usage(self) -> None:
2097 """Push current history pressure to the chip. Main thread only.
2099 Cheap: the same char/4 estimate the windower already uses, over messages
2100 that are in memory anyway. Recomputed per turn rather than per keystroke.
2101 """
2102 with self._history_lock:
2103 history = list(self._history)
2104 summary = self._summary
2105 budget = self._history_budget()
2106 used = sum(estimate_tokens(m) for m in history)
2107 used += sum(estimate_tokens(m) for m in summary_messages(summary))
2108 with contextlib.suppress(NoMatches):
2109 self.query_one("#context-chip", ContextChip).usage = used / max(1, budget)
2111 def _mark_context_boundary(self, *titles: str) -> None:
2112 """Draw rules in the log where the model's view of the chat changed.
2114 A rich Rule, not a hand-drawn "-- text --": it draws the line out to the
2115 full width itself, which is what makes it read as a boundary rather than
2116 as another message. Guarded because the worker can land this after the
2117 user has navigated off the chat screen.
2118 """
2119 # mount() is async: the anchor may not be in the log yet, and mounting
2120 # before a non-child raises. Appending reads fine in that race.
2121 anchor = self._active_question
2122 if anchor is not None and not anchor.is_mounted:
2123 anchor = None
2124 with contextlib.suppress(NoMatches):
2125 for title in titles:
2126 rule = Static(
2127 Rule(title=title, characters="─", style="dim"),
2128 classes="compaction-marker",
2129 )
2130 if anchor is None:
2131 self._chat_log.mount(rule)
2132 else:
2133 self._chat_log.mount(rule, before=anchor)
2135 def _on_history_trimmed(self, dropped: int) -> None:
2136 """Mark where turns left the model's view with nothing standing in for them.
2138 The compaction-off path. Same rule as compaction so the log reads
2139 consistently, different words because nothing was summarized.
2140 """
2141 self._refresh_context_usage()
2142 self._mark_context_boundary(msg.CHAT_TRIMMED.format(count=dropped))
2143 with contextlib.suppress(NoMatches):
2144 self.notify(msg.CHAT_TRIMMED_TOAST, severity="warning")
2146 def _on_history_compacted(self, condensed: int, stranded: int) -> None:
2147 """Mark where the model's memory of this conversation turns into a summary.
2149 Styling lives in chat.tcss under .compaction-marker. Guarded because the
2150 worker can land this after the user navigated off the chat screen.
2152 Stranded turns get their own line: they are gone from the model's view
2153 with nothing standing in for them, and a user whose model has forgotten
2154 something is owed the reason rather than left to infer it.
2155 """
2156 self._refresh_context_usage()
2157 titles = [msg.CHAT_COMPACTED.format(count=condensed)]
2158 if stranded:
2159 titles.append(msg.CHAT_COMPACTION_STRANDED.format(count=stranded))
2160 self._mark_context_boundary(*titles)
2161 with contextlib.suppress(NoMatches):
2162 self.notify(
2163 msg.CHAT_COMPACTED_STRANDED_TOAST if stranded else msg.CHAT_COMPACTED_TOAST,
2164 severity="warning",
2165 )
2167 def action_scroll_up(self) -> None:
2168 self._chat_log.scroll_page_up()
2170 def action_scroll_down(self) -> None:
2171 self._chat_log.scroll_page_down()
2173 def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
2174 """Keep the footer honest about mode-dependent bindings.
2176 - ``cancel_stream`` (Ctrl+C) only does something while streaming in
2177 INSERT mode; otherwise the App's Quit binding takes the slot.
2178 """
2179 if action == "cancel_stream":
2180 return self.streaming and not self.stopping and self._insert_mode
2181 if action in _MODE_ACTIONS:
2182 # A focused drawer keeps the mode keys for its own bindings.
2183 if self._focus_in_drawer():
2184 return False
2185 return action == "enter_normal_mode" or self._leaves_normal_mode(action)
2186 if action == "enter_model_strip":
2187 # NORMAL mode parks the cursor on the transcript, and that is the
2188 # only place these letters are free. Stated as where they DO apply,
2189 # so a drawer, a dialog or any later focus target keeps its own
2190 # letters without having to be named here.
2191 focused = self.focused
2192 return focused is not None and focused.id == "chat-log"
2193 return super().check_action(action, parameters)
2195 def action_enter_normal_mode(self) -> None:
2196 """Esc dismisses the overlay if visible; otherwise drops into NORMAL mode."""
2197 overlay = self._completion_overlay
2198 if overlay.is_visible:
2199 # Revert any previewed candidate back to what the user typed.
2200 if self._completion_origin is not None and self._chat_input.value != (
2201 self._completion_origin
2202 ):
2203 self._set_input(self._completion_origin)
2204 self._completion_origin = None
2205 overlay.hide()
2206 # Backing out of the command list leaves nothing worth keeping in
2207 # a lone slash, and it would hijack the next message as /word.
2208 if self._chat_input.value.strip() == "/":
2209 self._set_input("")
2210 return
2211 if isinstance(self.focused, Select) or self._focus_in_model_bar():
2212 # Leaving the model strip should put us back in INSERT so the
2213 # user can type their next prompt; routing through the helper
2214 # makes sure can_focus is re-enabled.
2215 self._enter_insert_mode()
2216 return
2217 self._insert_mode = False
2218 # Make the chat input unfocusable in NORMAL mode so Tab traversal
2219 # skips past it AND a programmatic focus restore (modal close,
2220 # screen pop) cannot land on it. The user re-enters INSERT
2221 # explicitly via i/a/o/Enter or by clicking the input.
2222 self._chat_input.can_focus = False
2223 self.set_focus(self._chat_log)
2224 self._update_input_style()
2226 def action_cancel_stream(self) -> None:
2227 """Stop the live answer. Bound to Ctrl+C from INSERT mode."""
2228 self._stop_turn()
2230 def _stop_turn(self) -> None:
2231 """Ask the live turn to stop and sever its inference call.
2233 The body sees the stop at the engine wait, before and after the fold,
2234 between tokens and in its error branch; ``cancel_inference`` severs the
2235 stream's transport to unblock a reader stuck in a socket read. The turn
2236 stays busy until its worker ends it.
2237 """
2238 turn = self._turn
2239 if turn is None:
2240 return
2241 turn.stop.set()
2242 get_services().cancel_inference()
2243 self.stopping = True
2245 def apply_model_change(self) -> None:
2246 """Swap to the new chat model without freezing the UI or losing an answer.
2248 Reloading the fleet for the new model is a multi-second restart, so it
2249 runs in a thread worker instead of on the event loop. The worker reloads
2250 only the chat role; the provider retires any still-busy client across the
2251 restart and serializes overlapping reloads, so the worker can start at
2252 once without waiting for other workers.
2254 A switch requested mid-answer is queued, not applied: restarting the chat
2255 server under a live stream kills the answer being read. The queued switch
2256 runs on leaving the streaming state, so it covers a finished, cancelled
2257 and cleared answer alike.
2258 """
2259 if self.swapping_model:
2260 # A swap is already loading; a second one (rapid /model, or the model
2261 # bar re-clicked while the input is disabled) would spawn a duplicate
2262 # worker and a duplicate completion toast. The in-flight reload already
2263 # coalesces onto the latest cfg, so ignore the re-entry.
2264 self.notify(msg.CHAT_MODEL_SWITCHING, severity="warning", timeout=3)
2265 return
2266 if self.streaming:
2267 from lilbee.catalog.formatting import display_label_for_ref
2269 # cfg already holds the new ref, so a second queued switch needs no
2270 # extra state.
2271 self._model_switch_queued = True
2272 self.app.notify(
2273 msg.MODEL_SWAP_QUEUED.format(name=display_label_for_ref(cfg.chat_model))
2274 )
2275 return
2276 self.swapping_model = True
2277 self.app.notify(msg.MODEL_SWAP_APPLYING)
2278 self._reload_chat_model_worker()
2280 def _apply_input_busy_state(self) -> None:
2281 """Disable the chat input while a swap or placement reload is loading, and
2282 say why in the placeholder so a person is never left facing a dead input
2283 with no explanation.
2285 Restores focus and the default placeholder when the fleet is idle again so
2286 the user can type without re-clicking the input that was disabled out from
2287 under them. Guarded because the unblock can fire (via ``call_from_thread``
2288 or a bubbled message) after the user navigated away and the input is no
2289 longer mounted.
2290 """
2291 no_model = not self.app.chat_is_ready
2292 busy = self.swapping_model or self.reloading_placement or no_model
2293 with contextlib.suppress(NoMatches):
2294 inp = self._chat_input
2295 inp.disabled = busy
2296 if no_model:
2297 inp.placeholder = msg.CHAT_INPUT_NO_MODEL
2298 elif self.swapping_model:
2299 from lilbee.catalog.formatting import display_label_for_ref
2301 inp.placeholder = msg.CHAT_INPUT_SWITCHING.format(
2302 name=display_label_for_ref(cfg.chat_model)
2303 )
2304 elif self.reloading_placement:
2305 inp.placeholder = msg.CHAT_INPUT_RELOADING
2306 else:
2307 inp.placeholder = msg.CHAT_INPUT_PLACEHOLDER_DEFAULT
2308 if not busy and self._insert_mode:
2309 self.set_focus(inp)
2311 def watch_swapping_model(self, swapping: bool) -> None:
2312 self._apply_input_busy_state()
2314 def watch_reloading_placement(self, reloading: bool) -> None:
2315 self._apply_input_busy_state()
2317 def on_fleet_body_placement_reloading(self, event: FleetBody.PlacementReloading) -> None:
2318 """Hold chat submissions while the Fleet drawer reloads the fleet."""
2319 self.reloading_placement = event.active
2321 @work(thread=True, name=_MODEL_SWAP_WORKER, exit_on_error=False)
2322 def _reload_chat_model_worker(self) -> None:
2323 """Reload the chat role and warm the new model before unblocking the input.
2325 ``reload_role(wait=True)`` re-plans and restarts the fleet for the new chat
2326 model (retrieval is untouched) and returns once the proxy is back up. The
2327 model is then warmed here rather than deferred to the user's next prompt:
2328 ``request_engine_warm`` drives the load and populates the provider warm
2329 tracker, which the task-bar footer renders (spinner, model, phase), and
2330 ``wait_chat_ready`` holds the input disabled until the model actually
2331 serves -- so the switch never hands back a live input in front of a model
2332 that has not loaded. The provider serializes overlapping reloads, so a
2333 rapid second swap coalesces onto the latest cfg.
2334 """
2335 from lilbee.app.placement import (
2336 chat_warm_error,
2337 request_engine_warm,
2338 wait_chat_ready,
2339 )
2341 worker = _get_worker()
2342 try:
2343 get_services().reload_role(WorkerRole.CHAT, wait=True)
2344 request_engine_warm()
2345 ready = wait_chat_ready(should_abort=lambda: worker.is_cancelled)
2346 except Exception as exc: # any reload failure becomes a toast, never a crash
2347 call_from_thread(self, self._on_model_swap_failed, str(exc))
2348 return
2349 if worker.is_cancelled:
2350 return
2351 error = None if ready else chat_warm_error()
2352 if error:
2353 call_from_thread(self, self._on_model_swap_failed, error)
2354 else:
2355 call_from_thread(self, self._on_model_swapped)
2357 def _on_model_swapped(self) -> None:
2358 """Main-thread completion: unblock the input and confirm the new model."""
2359 from lilbee.catalog.formatting import display_label_for_ref
2361 self.swapping_model = False
2362 self.app.notify(msg.MODEL_SWAP_DONE.format(name=display_label_for_ref(cfg.chat_model)))
2364 def _on_model_swap_failed(self, error: str) -> None:
2365 """Main-thread failure: unblock the input and surface the error."""
2366 self.swapping_model = False
2367 self.app.notify(msg.MODEL_SWAP_FAILED.format(error=error), severity="error")
2369 @on(Markdown.LinkClicked)
2370 def _open_answer_link(self, event: Markdown.LinkClicked) -> None:
2371 """Open a link clicked in an answer: ``file:`` citations open in the OS
2372 default app for the file type; web links open in the browser."""
2373 event.stop()
2374 if event.href.startswith("file://"):
2375 open_local_file(event.href)
2376 else:
2377 self.app.open_url(event.href)
2379 async def action_toggle_markdown(self) -> None:
2380 """Toggle between Markdown and plain-text rendering for chat responses."""
2381 cfg.markdown_rendering = not cfg.markdown_rendering
2382 use_md = cfg.markdown_rendering
2383 chat_log = self._chat_log
2384 for widget in chat_log.query(AssistantMessage):
2385 await widget.rebuild_content_widget(use_md)
2386 label = "Markdown" if use_md else "Plain text"
2387 self.notify(msg.CHAT_RENDERING.format(label=label))
2389 def _run_sync(self, *, force_rebuild: bool = False, prune_ignored: bool = False) -> None:
2390 """Enqueue a document sync (or full rebuild) in the task bar."""
2391 if self._sync_active:
2392 self.notify(msg.SYNC_ALREADY_ACTIVE, severity="warning")
2393 return
2394 from lilbee.cli.tui.task_queue import TaskType
2396 self._sync_active = True
2397 # Clear the pending hint so the bar shows live sync progress
2398 # instead of the stale "N docs to sync" line.
2399 self._task_bar.clear_pending_sync()
2401 def _target(reporter: ProgressReporter) -> None:
2402 try:
2403 self._do_sync(reporter, force_rebuild=force_rebuild, prune_ignored=prune_ignored)
2404 finally:
2405 self._sync_active = False
2406 # Re-detect after every sync attempt: success drives the
2407 # count to 0, failure or cancel leaves the still-pending
2408 # files counted so the hint reappears.
2409 self._task_bar.start_detect_pending()
2411 label = msg.TASK_NAME_REBUILD if force_rebuild else msg.TASK_NAME_SYNC
2412 self._task_bar.start_task(label, TaskType.SYNC, _target, indeterminate=True)
2414 def _do_sync(
2415 self,
2416 reporter: ProgressReporter,
2417 *,
2418 force_rebuild: bool = False,
2419 prune_ignored: bool = False,
2420 ) -> None:
2421 """Sync body. Runs on worker thread."""
2422 from lilbee.data.ingest import sync
2424 reporter.update(0, msg.SYNC_STATUS_SYNCING, indeterminate=True)
2425 on_progress = build_sync_progress_callback(reporter)
2426 try:
2427 result = asyncio_loop.run(
2428 sync(
2429 quiet=True,
2430 on_progress=on_progress,
2431 force_rebuild=force_rebuild,
2432 prune_ignored=prune_ignored,
2433 )
2434 )
2435 except asyncio.CancelledError as exc:
2436 raise RuntimeError(msg.SYNC_CANCELLED_RESUME) from exc
2437 if prune_ignored:
2438 call_from_thread(self, self.notify, msg.prune_ignored_message(len(result.removed)))
2439 if result.failed:
2440 raise RuntimeError(msg.SYNC_FAILED_FILES.format(files=", ".join(result.failed)))
2441 if result.skipped:
2442 call_from_thread(
2443 self,
2444 self.notify,
2445 msg.sync_skipped_message(result, tui_log_path()),
2446 severity="warning",
2447 )
2448 if result.held_out:
2449 call_from_thread(
2450 self,
2451 self.notify,
2452 msg.SYNC_HELD_OUT.format(count=len(result.held_out)),
2453 severity="warning",
2454 )
2456 def action_focus_commands(self) -> None:
2457 """Focus chat input and pre-fill with '/' for command entry."""
2458 # Route through the helper so can_focus is re-enabled when this
2459 # action fires from NORMAL mode; bare ``inp.focus()`` would
2460 # silently no-op while the input is intentionally unfocusable.
2461 self._enter_insert_mode()
2462 inp = self._chat_input
2463 if not inp.value.startswith("/"):
2464 inp.value = "/"
2465 inp.action_end()
2467 def action_toggle_chat_mode(self) -> None:
2468 """F3: flip between Search and Chat mode."""
2469 try:
2470 toggle = self.query_one(ChatModeToggle)
2471 except NoMatches:
2472 return
2473 if not toggle.toggle():
2474 return
2475 label = (
2476 msg.CHAT_MODE_SEARCH_LABEL
2477 if cfg.chat_mode == ChatMode.SEARCH.value
2478 else msg.CHAT_MODE_CHAT_LABEL
2479 )
2480 self.notify(msg.CHAT_MODE_SET.format(label=label))
2482 def action_cycle_scope(self) -> None:
2483 """``s``: cycle the scope chip when it is currently visible."""
2484 from lilbee.cli.tui.widgets.scope_chip import ScopeChip
2486 try:
2487 chip = self.query_one("#scope-chip", ScopeChip)
2488 except NoMatches:
2489 return
2490 if chip.has_class("-hidden"):
2491 return
2492 chip.cycle_scope()
2494 def action_complete(self) -> None:
2495 """Tab: fill the shared prefix, then cycle matches (readline / vim style).
2497 - Insert mode + chat input focused + dropdown closed but matches
2498 exist: open it, fill the longest common prefix, else preview the
2499 first match.
2500 - Insert mode + chat input focused + dropdown open: fill any further
2501 shared prefix, otherwise preview the next match.
2502 - Insert mode + chat input focused + no matches: insert ``\\t`` so
2503 users can type tab characters directly.
2504 - Normal mode or focus elsewhere: advance through the focus
2505 chain so Tab still walks every focusable widget.
2506 """
2507 inp = self._chat_input
2508 if not self._insert_mode or not inp.has_focus:
2509 self._tab_into_fleet_or_next()
2510 return
2511 overlay = self._completion_overlay
2512 if not overlay.is_visible and not self._open_completions():
2513 inp.insert("\t")
2514 return
2515 if self._fill_common_prefix():
2516 return
2517 self._preview_next()
2519 def _focus_in_drawer(self) -> bool:
2520 """True when keyboard focus is inside an open drawer, so Esc / Enter / i / a / o
2521 reach that drawer's own controls instead of switching the chat mode.
2523 Asked of the Drawer base rather than one drawer class: a drawer that had
2524 to name itself here would otherwise swallow its own Enter until someone
2525 noticed.
2526 """
2527 return drawer_holding(self.focused) is not None
2529 def _focus_in_model_bar(self) -> bool:
2530 """True when focus is on any model-strip member.
2532 Asked of the container rather than of each member class so a member
2533 added later is covered without a second edit here.
2534 """
2535 focused = self.focused
2536 return bool(focused and any(isinstance(n, ModelBar) for n in focused.ancestors_with_self))
2538 def _tab_into_fleet_or_next(self) -> None:
2539 """Jump Tab into the open Fleet drawer's first toggle so the placement
2540 editor is reachable without tabbing past every widget; once focus is
2541 inside the drawer, Tab cycles within it as usual."""
2542 drawers = self.screen.query(FleetDrawer)
2543 if not drawers:
2544 self.screen.focus_next()
2545 return
2546 drawer = drawers.first()
2547 focused = self.screen.focused
2548 inside = focused is not None and drawer in focused.ancestors_with_self
2549 toggles = drawer.query(".dev-toggle")
2550 if not inside and toggles:
2551 self.set_focus(toggles.first())
2552 return
2553 self.screen.focus_next()
2555 def action_complete_next(self) -> None:
2556 """Ctrl+N: preview the next match, opening the dropdown if it is closed (vim ``<C-n>``)."""
2557 if not self._chat_input.has_focus:
2558 # Not a completion here; skip so an open overlay (e.g. the sessions
2559 # drawer) can bind Ctrl+N instead of this priority binding eating it.
2560 raise SkipAction()
2561 if self._completion_overlay.is_visible or self._open_completions():
2562 self._preview_next()
2564 def action_complete_prev(self) -> None:
2565 """Ctrl+P: preview the previous match, opening the dropdown if it is closed."""
2566 if not self._chat_input.has_focus:
2567 return
2568 if self._completion_overlay.is_visible or self._open_completions():
2569 self._preview_prev()
2571 def _preview_next(self) -> None:
2572 """Preview the highlighted match if none is previewed yet, else step forward."""
2573 overlay = self._completion_overlay
2574 if self._chat_input.value == self._completion_origin:
2575 display = overlay.get_current()
2576 else:
2577 display = overlay.cycle_next()
2578 if display is not None:
2579 self._preview_completion(display)
2581 def _preview_prev(self) -> None:
2582 """Step the highlight backward (wrapping to the last match) and preview it."""
2583 display = self._completion_overlay.cycle_prev()
2584 if display is not None:
2585 self._preview_completion(display)
2587 def _open_completions(self) -> bool:
2588 """Show the dropdown for the current input and remember it as the origin."""
2589 options = get_completions(self._chat_input.value)
2590 if not options:
2591 return False
2592 self._completion_origin = self._chat_input.value
2593 self._completion_overlay.show_completions(options)
2594 return True
2596 def _completion_value(self, display: str) -> str:
2597 """Full input text produced by accepting ``display``, keeping the typed prefix.
2599 Path completions are basenames, so the directory the user already
2600 typed (``~/``, ``./``, absolute) is preserved and only the final
2601 segment is replaced.
2602 """
2603 text = (
2604 self._completion_origin
2605 if self._completion_origin is not None
2606 else (self._chat_input.value)
2607 )
2608 if " " not in text:
2609 return display
2610 cmd, _, partial = text.partition(" ")
2611 if cmd.lower() in PATH_ARG_COMMANDS:
2612 head = path_completion_prefix(partial)
2613 return f"{cmd} {head}{display}"
2614 return f"{cmd} {display}"
2616 def _set_input(self, value: str) -> None:
2617 """Replace the input value without triggering the live-refresh of the dropdown."""
2618 inp = self._chat_input
2619 if inp.value == value:
2620 return
2621 # The setter posts Changed asynchronously; flag one event to ignore so
2622 # the previewed candidate doesn't re-filter (and collapse) the dropdown.
2623 # (The value setter already moves the cursor to the end.)
2624 self._suppress_refresh += 1
2625 inp.value = value
2627 def _preview_completion(self, display: str) -> None:
2628 """Write the highlighted candidate into the input, leaving the dropdown open."""
2629 self._set_input(self._completion_value(display))
2631 def _fill_common_prefix(self) -> bool:
2632 """Extend the input to the longest prefix shared by all matches; True if it grew."""
2633 overlay = self._completion_overlay
2634 values = [self._completion_value(d) for d in overlay.options]
2635 shared = longest_common_prefix(values)
2636 if len(shared) <= len(self._chat_input.value):
2637 return False
2638 self._set_input(shared)
2639 # Re-filter for the newly completed prefix (descends into a directory,
2640 # narrows the model list, etc.).
2641 self._refresh_completion_overlay()
2642 return True
2644 def action_history_prev(self) -> None:
2645 """Up arrow: cycle the dropdown if visible, else recall previous history entry."""
2646 if not self._insert_mode:
2647 raise SkipAction()
2648 inp = self._chat_input
2649 if not inp.has_focus:
2650 raise SkipAction()
2651 # When the completion dropdown is up, Up navigates the dropdown
2652 # (vim/Emacs-style) rather than recalling history.
2653 overlay = self._completion_overlay
2654 if overlay.is_visible:
2655 self._preview_prev()
2656 return
2657 if not self._input_history:
2658 raise SkipAction()
2659 if self._history_index == -1:
2660 self._history_index = len(self._input_history) - 1
2661 elif self._history_index > 0:
2662 self._history_index -= 1
2663 else:
2664 return
2665 inp.value = self._input_history[self._history_index]
2666 inp.action_end()
2668 def action_history_next(self) -> None:
2669 """Down arrow: cycle the dropdown if visible, else recall next history entry."""
2670 if not self._insert_mode:
2671 raise SkipAction()
2672 inp = self._chat_input
2673 if not inp.has_focus:
2674 raise SkipAction()
2675 # When the completion dropdown is up, Down navigates the dropdown.
2676 overlay = self._completion_overlay
2677 if overlay.is_visible:
2678 self._preview_next()
2679 return
2680 if self._history_index == -1:
2681 raise SkipAction()
2682 if self._history_index < len(self._input_history) - 1:
2683 self._history_index += 1
2684 inp.value = self._input_history[self._history_index]
2685 inp.action_end()
2686 else:
2687 self._history_index = -1
2688 inp.value = ""
2690 @on(ChatInput.Changed, "#chat-input")
2691 def _on_chat_input_changed(self, event: ChatInput.Changed) -> None:
2692 """Refresh arg-hint and auto-show or hide the completion dropdown."""
2693 if self._suppress_refresh > 0:
2694 # A programmatic edit (preview / accept / revert) is managing the
2695 # overlay itself; consume one Changed and skip the live refresh.
2696 self._suppress_refresh -= 1
2697 self._refresh_arg_hint()
2698 return
2699 self._refresh_completion_overlay()
2700 self._refresh_arg_hint()
2702 def _refresh_completion_overlay(self) -> None:
2703 """Live-filter the dropdown against the current input, in command and arg modes alike."""
2704 overlay = self._completion_overlay
2705 text = self._chat_input.value
2706 options = get_completions(text)
2707 if options:
2708 self._completion_origin = text
2709 overlay.show_completions(options)
2710 elif overlay.is_visible:
2711 overlay.hide()
2712 self._completion_origin = None
2714 def _refresh_arg_hint(self) -> None:
2715 """Push the current input value into the ArgHintLine."""
2716 self._arg_hint.update_for_input(self._chat_input.value)
2718 def refresh_model_bar(self) -> None:
2719 """Re-scan installed models and refresh the model bar.
2721 Show can arrive before the prompt area's descendants have mounted, and
2722 on a slow runner it does. A bar that is not in the DOM yet scans on its
2723 own mount, so a missing bar means the scan is already coming, not that
2724 it was skipped -- the re-scan here is what re-entering the screen needs.
2725 Querying regardless raised NoMatches out of the show handler, which
2726 Textual re-raises as an app crash.
2727 """
2728 for bar in self.query("#model-bar").results(ModelBar):
2729 bar.refresh_models()
2731 def action_vim_scroll_down(self) -> None:
2732 """Vim j: scroll down in normal mode."""
2733 if self._insert_mode:
2734 raise SkipAction()
2735 self._chat_log.scroll_down()
2737 def action_vim_scroll_up(self) -> None:
2738 """Vim k: scroll up in normal mode."""
2739 if self._insert_mode:
2740 raise SkipAction()
2741 self._chat_log.scroll_up()
2743 def action_vim_scroll_home(self) -> None:
2744 """Vim g: scroll to top in normal mode."""
2745 if self._insert_mode:
2746 raise SkipAction()
2747 self._chat_log.scroll_home()
2749 def action_vim_scroll_end(self) -> None:
2750 """Vim G: scroll to bottom in normal mode."""
2751 if self._insert_mode:
2752 raise SkipAction()
2753 self._chat_log.scroll_end()
2755 def action_half_page_down(self) -> None:
2756 """Ctrl-D: half-page down (vim style)."""
2757 log_widget = self._chat_log
2758 half = max(1, log_widget.size.height // 2)
2759 log_widget.scroll_relative(y=half)
2761 def action_half_page_up(self) -> None:
2762 """Ctrl-U: half-page up (vim style)."""
2763 log_widget = self._chat_log
2764 half = max(1, log_widget.size.height // 2)
2765 log_widget.scroll_relative(y=-half)