Coverage for src/lilbee/cli/tui/screens/chat.py: 100%
1734 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
1"""Chat screen: scrollable message log with streaming markdown responses."""
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 pydantic import ValidationError
20from rich.rule import Rule
21from textual import events, getters, on, work
22from textual.actions import SkipAction
23from textual.app import ComposeResult
24from textual.binding import Binding, BindingType
25from textual.containers import Horizontal, Vertical, VerticalScroll
26from textual.content import Content
27from textual.css.query import NoMatches
28from textual.dom import DOMNode
29from textual.message import Message
30from textual.reactive import reactive
31from textual.screen import Screen
32from textual.widget import Widget
33from textual.widgets import Footer, Markdown, Select, Static
35# Cancellation check for @work(thread=True) workers. Import at module level
36# since it's used in multiple methods.
37from textual.worker import NoActiveWorker
38from textual.worker import get_current_worker as _get_worker
40from lilbee.app.analyze import hide_tip
41from lilbee.app.ingest import (
42 LIBRARY_REFUSALS,
43 AddRollback,
44 RegisterResult,
45 forget_unfinished_on_cancel,
46 leave_as_cancel,
47 removable_names,
48 remove_documents_durably,
49)
50from lilbee.app.services import get_services, reset_store
51from lilbee.app.session_export import write_session_markdown
52from lilbee.app.session_sources import message_sources
53from lilbee.app.settings import list_setting_value
54from lilbee.app.settings_map import SETTINGS_MAP, SettingDef
55from lilbee.app.themes import DARK_THEMES
56from lilbee.app.version import get_version
57from lilbee.cli.tui import messages as msg
58from lilbee.cli.tui.app import LilbeeApp, apply_active_model
59from lilbee.cli.tui.command_registry import (
60 ANALYZE_OFF_ARG,
61 ANALYZE_REPORT_ARG,
62 runs_while_streaming,
63)
64from lilbee.cli.tui.log_routing import tui_log_path
65from lilbee.cli.tui.screens.analyze_report import open_report, start_analysis
66from lilbee.cli.tui.screens.chat_helpers import (
67 add_indexed_anything,
68 build_add_progress_callback,
69 build_import_progress_callback,
70 build_sync_progress_callback,
71 close_stream,
72 open_local_file,
73 remember_from_input,
74 unregister_added_roots,
75)
76from lilbee.cli.tui.screens.profile_dialogs import run_profile_op, start_profile_switch
77from lilbee.cli.tui.thread_safe import call_from_thread, post_from_thread
78from lilbee.cli.tui.widgets.arg_hint import ArgHintLine
79from lilbee.cli.tui.widgets.autocomplete import (
80 PATH_ARG_COMMANDS,
81 CompletionOverlay,
82 get_completions,
83 list_model_options,
84 longest_common_prefix,
85 model_completions,
86 path_completion_prefix,
87 reads_a_folder,
88 reads_models,
89)
90from lilbee.cli.tui.widgets.chat_input import ChatInput
91from lilbee.cli.tui.widgets.context_chip import ContextChip
92from lilbee.cli.tui.widgets.drawer import drawer_holding, first_direct_child
93from lilbee.cli.tui.widgets.fleet_body import FleetBody
94from lilbee.cli.tui.widgets.fleet_drawer import FleetDrawer
95from lilbee.cli.tui.widgets.fork_picker import ForkPicker, fork_points
96from lilbee.cli.tui.widgets.help_hint import HelpHint
97from lilbee.cli.tui.widgets.message import AssistantMessage, UserMessage
98from lilbee.cli.tui.widgets.model_bar import ChatModeToggle, ModelBar
99from lilbee.cli.tui.widgets.slash_command_catalog import SlashCommandCatalog
100from lilbee.cli.tui.widgets.status_bar import ViewTabs
101from lilbee.cli.tui.widgets.task_bar import TaskBar
102from lilbee.cli.tui.widgets.task_bar_controller import ProgressReporter
103from lilbee.core.config import cfg
104from lilbee.core.config.enums import ChatMode, CrawlRenderMode
105from lilbee.core.config.model import CLEARABLE_MODEL_FIELDS
106from lilbee.core.text import strip_control_characters
107from lilbee.crawler import (
108 CRAWLER_MISSING_MESSAGE,
109 crawler_available,
110 is_url,
111 require_valid_crawl_url,
112)
113from lilbee.crawler.events import BROWSER_SETUP_COMPONENT
114from lilbee.crawler.models import NO_LOGIN, CrawlLogin, crawl_limit_error
115from lilbee.crawler.stored_login import crawl_login
116from lilbee.crawler.url_filter import without_userinfo
117from lilbee.data.store import (
118 ChunkType,
119 EmbeddingModelMismatchError,
120 SearchScope,
121 scope_to_chunk_type,
122)
123from lilbee.providers.roles import WorkerRole
124from lilbee.providers.warm_progress import WarmPhase, WarmProgress
125from lilbee.retrieval.embedder import is_model_available
126from lilbee.retrieval.query import ChatMessage
127from lilbee.retrieval.query.compaction import (
128 compaction_due,
129 foldable,
130 history_budget,
131 overflow,
132 prompt_history,
133 summary_messages,
134)
135from lilbee.retrieval.query.formatting import AnswerSources, SourceRef
136from lilbee.retrieval.query.history_window import estimate_tokens
137from lilbee.retrieval.reasoning import RetrievalNotice
138from lilbee.runtime import asyncio_loop
139from lilbee.runtime.cancellation import TaskCancelledError
140from lilbee.runtime.lock import ResetRefusedError
141from lilbee.runtime.progress import (
142 EventType,
143 ProgressEvent,
144 SetupStartEvent,
145)
146from lilbee.sessions import (
147 MessageRole,
148 Session,
149 SessionMessage,
150 SessionNotFoundError,
151 SessionOrigin,
152 SessionOwnershipError,
153 SessionStore,
154 StoredSourceRef,
155 TitleSource,
156 derive_title,
157 message_with_refs,
158)
160if TYPE_CHECKING:
161 from lilbee.cli.tui.widgets.task_bar_controller import TaskBarController
162 from lilbee.data.types import SyncResult
163log = logging.getLogger(__name__)
165# Coalesce per-token UI updates into ~50 ms windows. Tiny reasoning models can
166# emit 100+ tokens/sec; one ``call_from_thread`` per token saturates Textual's
167# message queue and makes key events visibly lag.
168_STREAM_FLUSH_INTERVAL = 0.05
171@dataclass
172class _StreamTimings:
173 """Last-fired monotonic timestamp for the stream flush."""
175 last_flush: float
178@dataclass
179class _Turn:
180 """One live question: its stop signal, answer bubble, conversation and session."""
182 question: str
183 widget: AssistantMessage
184 generation: int
185 session_id: str | None
186 stop: threading.Event = field(default_factory=threading.Event)
187 answer: str = ""
188 # The answer's Sources list, kept out of ``answer``.
189 source_refs: list[SourceRef] = field(default_factory=list)
192class _TurnEnded(Message):
193 """Posted by a turn's worker once its body has ended, whether or not the answer saved."""
195 bubble = False
197 def __init__(self, turn: _Turn) -> None:
198 super().__init__()
199 self.turn = turn
202# The stream worker's group. Only Textual's teardown cancels it: a turn stops
203# through its stop signal, so its body always runs and always ends the turn.
204_TURN_WORKER_GROUP = "chat_turn"
205_SYNC_FAILED_TOAST_SECONDS = 8.0
208# ``/crawl`` command flags.
209_CRAWL_FLAG_DEPTH = "--depth"
210_CRAWL_FLAG_MAX_PAGES = "--max-pages"
211_CRAWL_FLAG_INCLUDE_SUBDOMAINS = "--include-subdomains"
212_CRAWL_FLAG_RENDER = "--render"
214# Name for the thread worker that resets and warms the new chat model off the
215# event loop.
216_MODEL_SWAP_WORKER = "model_swap_reset"
218# The NORMAL / INSERT mode keys (esc, i, a, o, enter). A focused drawer keeps them.
219_MODE_ACTIONS = frozenset({"enter_normal_mode", "insert_mode", "insert_or_send"})
222def _engine_status_text(snapshot: WarmProgress) -> str:
223 """One status line for an engine-load snapshot: byte progress or the phase."""
224 if snapshot.phase is WarmPhase.READING_WEIGHTS and snapshot.bytes_total:
225 from lilbee.catalog.formatting import display_label_for_ref
227 name = display_label_for_ref(snapshot.model_ref) if snapshot.model_ref else ""
228 pct = snapshot.bytes_done * 100 // snapshot.bytes_total
229 return f"{msg.ENGINE_READING_WEIGHTS.format(name=name)} {pct}%"
230 if snapshot.phase is WarmPhase.LOADING_ENGINE:
231 return msg.ENGINE_ALMOST_READY
232 return msg.ENGINE_WARMING
235_SETTING_TYPE_HINTS: dict[type, str] = {int: "a whole number", float: "a number"}
238def _stream_error_text(exc: Exception) -> str:
239 """The note for a failed answer; a severed engine socket names the problem, not the OS error."""
240 if isinstance(exc, ConnectionError):
241 return msg.STREAM_DISCONNECTED
242 return msg.STREAM_ERROR.format(error=exc)
245def _set_echo(key: str, parsed: object, *, secret: bool, show_stored: bool) -> object:
246 """What /set shows after a write: a bool or list setting shows what it stored."""
247 if secret and parsed:
248 return msg.MASKED_VALUE
249 if not show_stored:
250 return parsed
251 stored = getattr(cfg, key)
252 if stored is None:
253 return msg.CMD_SET_AUTO
254 return stored if stored != [] else msg.CMD_SET_EMPTY_LIST
257def _refusal_text(exc: ValueError | TypeError) -> str:
258 """Why a write refused, as one short sentence: a validator's own message, else the text."""
259 if isinstance(exc, ValidationError):
260 return str(exc.errors()[0]["msg"]).removeprefix("Value error, ")
261 return str(exc)
264def _setting_type_hint(kind: type) -> str:
265 """Human phrase for what a settings value must be."""
266 return _SETTING_TYPE_HINTS.get(kind, f"a valid {kind.__name__} value")
269def _closest_source(name: str, known: set[str]) -> str | None:
270 """The indexed name most likely meant by *name*, or None when nothing is close."""
271 low = name.lower()
272 contains = [k for k in known if low in k.lower()]
273 if len(contains) == 1:
274 return contains[0]
275 matches = difflib.get_close_matches(name, sorted(known), n=1, cutoff=0.6)
276 return matches[0] if matches else None
279def _whole_path(args: str) -> Path:
280 """The whole argument as one path, with surrounding quotes removed and ``~`` expanded."""
281 return Path(args.strip().strip('"').strip("'")).expanduser()
284def _parse_add_paths(args: str) -> list[Path]:
285 """Resolve ``/add`` arguments to filesystem paths.
287 A single unquoted path may contain spaces and apostrophes (e.g. macOS
288 "Star Wars Collector's Edition.pdf"), which shell parsing would split into
289 fragments or reject with "No closing quotation". So when the whole argument
290 points at an existing file or directory, take it as one path; otherwise fall
291 back to shell-style splitting for multiple, optionally quoted, paths.
292 """
293 whole = _whole_path(args)
294 if whole.exists():
295 return [whole]
296 try:
297 # posix=False on Windows keeps backslash path separators literal.
298 tokens = shlex.split(args, posix=os.name != "nt")
299 except ValueError:
300 return [whole] # unbalanced quote in a literal path; treat as one path
301 if os.name == "nt":
302 tokens = [t.strip('"').strip("'") for t in tokens]
303 return [Path(token).expanduser() for token in tokens]
306class ChatWelcome(Static):
307 """Empty-state welcome posted into the chat log; removed on first message."""
309 def __init__(self, *, id: str | None = None) -> None:
310 super().__init__(self._body(msg.CHAT_WELCOME_HINT), id=id)
312 @staticmethod
313 def _body(hint_text: str) -> Content:
314 title = Content.styled(msg.CHAT_WELCOME_TITLE, "bold $primary")
315 tagline = Content.styled(msg.CHAT_WELCOME_TAGLINE, "$text-muted")
316 hint = Content.styled(hint_text, "$text-muted")
317 return Content.assemble(title, "\n", tagline, "\n\n", hint)
319 def set_no_model(self, no_model: bool) -> None:
320 """Swap the hint line between "just ask" and the route to a chat model."""
321 hint = msg.CHAT_WELCOME_NO_MODEL_HINT if no_model else msg.CHAT_WELCOME_HINT
322 self.update(self._body(hint))
325class PromptArea(Vertical):
326 """Container for chat input that highlights on focus-within."""
328 pass
331class ChatScreen(Screen[None]):
332 """Primary chat interface with streaming LLM responses."""
334 # Lilbee always hosts screens on a LilbeeApp (production + LilbeeAppHost
335 # in tests), so narrowing the type lets the screen call set_theme /
336 # switch_view / task_bar without isinstance dance or # type: ignore.
337 app: LilbeeApp # type: ignore[assignment]
339 CSS_PATH = "chat.tcss"
340 AUTO_FOCUS = "#chat-input"
342 # True from the start of a turn until its worker has ended it, so it also
343 # covers a stopped turn whose body is still unwinding.
344 streaming: reactive[bool] = reactive(False)
345 # True once the live turn was asked to stop; hides the Ctrl+C cancel.
346 stopping: reactive[bool] = reactive(False)
347 # True while a chat-model swap's fleet reload runs in the background. Gates the
348 # submit handler and disables the input so the user can't fire a prompt into a
349 # half-loaded fleet; cleared when the swap worker finishes (or fails).
350 swapping_model: reactive[bool] = reactive(False)
351 # True while a placement apply/clear reloads the fleet (from the Fleet drawer);
352 # holds chat submissions so they don't race the reload.
353 reloading_placement: reactive[bool] = reactive(False)
355 HELP = (
356 "# Chat\n\n"
357 "Ask questions about your knowledge base.\n\n"
358 "Press **Escape** for normal mode (vim keys), "
359 "**i**/**a**/**o** to return to insert mode.\n\n"
360 "**/** opens the slash-command line and **Tab** completes what you "
361 "type there; **F2** lists every command.\n\n"
362 "**F6** jumps to the model strip under the prompt, and in normal mode "
363 "**h** / **l** or **Left** / **Right** step into it from either end. "
364 "**Left** / **Right** walk all six cells (the Chat, Embed, Vision and "
365 "Rerank pickers, then the Search and Chat mode pills), **h** / **l** do "
366 "the same, **Home** / **End** jump to either end, **Enter** opens or "
367 "picks the focused cell, **Escape** goes back."
368 )
370 _SCROLL_GROUP = Binding.Group("Scroll", compact=True)
372 # Hot-path widget refs. ``getters.query_one`` is a typed class-level
373 # descriptor that resolves via Textual's indexed DOM lookup on every
374 # access. It is O(1) for id selectors, so no cache is needed.
375 _chat_input = getters.query_one("#chat-input", ChatInput)
376 _chat_log = getters.query_one("#chat-log", VerticalScroll)
377 _completion_overlay = getters.query_one("#completion-overlay", CompletionOverlay)
378 _arg_hint = getters.query_one("#arg-hint", ArgHintLine)
380 BINDINGS: ClassVar[list[BindingType]] = [
381 # `/` opens the slash-command line: the one thing this screen is for
382 # besides typing, so it keeps a footer cell.
383 Binding("slash", "focus_commands", "Commands", show=True),
384 # F2 opens the searchable list of every slash command
385 # (SlashCommandCatalog) -- not the model catalog, which is `/models`.
386 # Help-panel only: `/` already leads there, and the full list is a lookup.
387 Binding(
388 "f2",
389 "show_command_catalog",
390 "All commands",
391 show=False,
392 priority=True,
393 ),
394 # Hidden: Tab only completes while the slash dropdown is open, and the
395 # rest of the time it walks the focus chain, so a permanent
396 # "tab Complete" cell overstated it. Named in help beside `/`.
397 Binding("tab", "complete", "Complete", show=False, priority=True),
398 Binding("ctrl+n", "complete_next", "Next match", show=False, priority=True),
399 # Ctrl+P stays bound to the app's command palette by default. The
400 # chat screen only intercepts it WHEN the dropdown is visible, via
401 # LilbeeApp.action_command_palette overriding to call
402 # ChatScreen.action_complete_prev. Action is exposed for direct
403 # callers / tests; not bound here so the app-level priority binding
404 # for ctrl+p (palette) wins by default.
405 Binding("pageup", "scroll_up", "PgUp", show=False, group=_SCROLL_GROUP),
406 Binding("pagedown", "scroll_down", "PgDn", show=False, group=_SCROLL_GROUP),
407 Binding("ctrl+d", "half_page_down", "^d half PgDn", show=False, group=_SCROLL_GROUP),
408 Binding("ctrl+u", "half_page_up", "^u half PgUp", show=False, group=_SCROLL_GROUP),
409 Binding("j", "vim_scroll_down", "j down", show=False, group=_SCROLL_GROUP),
410 Binding("k", "vim_scroll_up", "k up", show=False, group=_SCROLL_GROUP),
411 Binding("g", "vim_scroll_home", "g top", show=False, group=_SCROLL_GROUP),
412 Binding("G", "vim_scroll_end", "G bottom", show=False, group=_SCROLL_GROUP),
413 # priority=True keeps history navigation fast-path winning over the
414 # ChatInput's TextArea cursor_up/_down. Multi-line cursor movement
415 # inside the prompt still works via PgUp/PgDn/Home/End.
416 Binding("up", "history_prev", "Up", show=False, priority=True),
417 Binding("down", "history_next", "Down", show=False, priority=True),
418 # Esc always drops back into NORMAL mode so the user can navigate
419 # the terminal. Cancel-while-streaming is on Ctrl+C below; the
420 # two roles used to share Esc and clobbered each other.
421 Binding("escape", "enter_normal_mode", "Normal mode", show=True, priority=True),
422 # Ctrl+C cancels the active stream when streaming AND in INSERT
423 # mode so the user can interrupt without leaving the input. The
424 # screen-level priority binding overrides the App-level Quit;
425 # check_action below hides + disables it outside that exact
426 # context, so Ctrl+C still quits the app from NORMAL or when
427 # nothing is streaming.
428 Binding("ctrl+c", "cancel_stream", "Cancel stream", show=True, priority=True),
429 Binding("ctrl+r", "toggle_markdown", "Markdown", show=False),
430 Binding("s", "cycle_scope", "Scope", show=False),
431 Binding("f3", "toggle_chat_mode", "Search/Chat", show=False),
432 # A function key, not a letter: the four role pickers are worth
433 # reaching mid-sentence, and a focused input consumes printable keys
434 # before any binding fires. Tab reaches the bar too, but only from
435 # NORMAL mode and only after walking past the log.
436 Binding("f6", "focus_model_bar", "Model bar", show=False, priority=True),
437 # NORMAL mode walks sideways into the role strip. h / l rather than the
438 # whole of hjkl: the transcript owns j / k for scrolling. The NORMAL
439 # mode keys that move focus (h, l, i, a, o and Enter) are priority
440 # bindings, so focus moves in the key's own step and the keys typed
441 # after them follow it.
442 Binding("h", "enter_model_strip(-1)", "Prev role", show=False, priority=True),
443 Binding("l", "enter_model_strip(1)", "Next role", show=False, priority=True),
444 Binding("i", "insert_mode", "Insert", show=False, priority=True),
445 Binding("a", "insert_mode", "Insert", show=False, priority=True),
446 Binding("o", "insert_mode", "Insert", show=False, priority=True),
447 Binding("enter", "insert_or_send", "Insert", show=False, priority=True),
448 # The arrows reach here too. The focused transcript is a VerticalScroll
449 # and binds Left / Right to horizontal scrolling, but Widget's
450 # action_scroll_left raises SkipAction when there is nothing to scroll
451 # sideways, which resumes the key lookup and lands it here. A transcript
452 # wide enough to scroll keeps its own arrows; h / l are unconditional.
453 Binding("left", "enter_model_strip(-1)", "Prev role", show=False),
454 Binding("right", "enter_model_strip(1)", "Next role", show=False),
455 ]
457 def __init__(self) -> None:
458 super().__init__()
459 self._history: list[ChatMessage] = []
460 # Rolling summary of the turns compaction has folded out of _history.
461 # Guarded by _history_lock alongside the turns it stands in for.
462 self._summary = ""
463 self._history_lock = threading.Lock()
464 # Which conversation _history holds; bumped under _history_lock whenever
465 # it is replaced, so a turn's late fold can tell it no longer applies.
466 self._conversation_generation = 0
467 # The saved session this conversation persists to. None until the first
468 # user turn creates one; reset to None on /clear so the next turn opens a
469 # fresh session.
470 self._session_id: str | None = None
471 # The live turn; set with the busy flag and cleared only when it ends.
472 self._turn: _Turn | None = None
473 self._insert_mode: bool = True
474 # Count of programmatic input edits whose (async) Changed events should
475 # not re-filter the dropdown. The setter posts Changed after our flag
476 # window would close, so a counter consumed in the handler is used.
477 self._suppress_refresh = 0
478 # The user-typed text the open dropdown is filtering against. While
479 # navigating, the input holds a previewed candidate; Esc restores this.
480 self._completion_origin: str | None = None
481 # The installed models, listed once while the input holds a model
482 # argument (/model, /remove) and dropped when it no longer does.
483 self._model_names: list[str] | None = None
484 self._listing_models = False
485 # Bumped when the input leaves the model argument; a listing started
486 # before the bump is stale and its result is dropped.
487 self._model_session = 0
488 # The latest model completion asked for while the listing runs.
489 self._model_request: tuple[str, Callable[[bool], None] | None] = ("", None)
490 self._rebuild_owed = False
491 self._input_history: list[str] = []
492 self._history_index: int = -1
493 # The warm tip is worth one toast per session, on the first prompt that
494 # has to wait out a cold engine load.
495 self._warm_tip_shown: bool = False
496 # The latest turn's question; a context boundary mounts above it, never
497 # after it. Outlives its turn: the next send overwrites it and a reset
498 # clears it.
499 self._active_question: UserMessage | None = None
500 # A model switch asked for mid-answer, applied once the stream ends.
501 self._model_switch_queued: bool = False
502 self._command_handlers: dict[str, Callable[[str], None]] = self._build_command_handlers()
504 def _build_command_handlers(self) -> dict[str, Callable[[str], None]]:
505 """Bind every COMMANDS entry to its handler method on this instance.
507 Run once at construction so /handle_slash dispatches via direct method
508 reference (no per-call getattr-by-string-name reflection).
509 """
510 from lilbee.cli.tui.command_registry import COMMANDS
512 handlers: dict[str, Callable[[str], None]] = {}
513 for cmd in COMMANDS:
514 method = getattr(self, cmd.handler)
515 for name in (cmd.name, *cmd.aliases):
516 handlers[name] = method
517 return handlers
519 @property
520 def _task_bar(self) -> TaskBarController:
521 """The app-level TaskBarController (always set by LilbeeApp)."""
522 return self.app.task_bar
524 def compose(self) -> ComposeResult:
525 from lilbee.cli.tui.widgets.bottom_bars import BottomBars
526 from lilbee.cli.tui.widgets.scope_chip import ScopeChip
527 from lilbee.cli.tui.widgets.top_bars import TopBars
529 with TopBars():
530 yield ViewTabs()
531 yield VerticalScroll(
532 ChatWelcome(id="chat-welcome"),
533 id="chat-log",
534 )
535 with BottomBars():
536 # Sits directly above the prompt area so it never covers the line
537 # you're typing (the input stays pinned to the bottom edge).
538 yield CompletionOverlay(id="completion-overlay")
539 with PromptArea(id="chat-prompt-area"):
540 yield ScopeChip(id="scope-chip")
541 yield ChatInput(
542 placeholder=msg.CHAT_INPUT_PLACEHOLDER_DEFAULT,
543 id="chat-input",
544 )
545 yield ArgHintLine(id="arg-hint")
546 yield ModelBar(id="model-bar")
547 yield TaskBar()
548 # The context reading shares the hint band instead of costing the
549 # prompt block its own row.
550 with Horizontal(id="hint-row"):
551 yield HelpHint(id="help-hint")
552 yield ContextChip(id="context-chip")
553 yield Footer()
555 def on_unmount(self) -> None:
556 self._task_bar.unsubscribe_work_end(self._start_owed_rebuild)
558 def on_mount(self) -> None:
559 self._task_bar.subscribe_work_end(self._start_owed_rebuild)
560 self._update_input_style()
561 self.app.settings_changed_signal.subscribe(self, self._on_settings_changed)
562 # init=True paints the empty state on first mount when the gate landed
563 # the app on the catalog and the user navigated here without a model.
564 self.watch(self.app, "chat_is_ready", self._on_chat_ready_changed, init=True)
566 def on_show(self) -> None:
567 """Called when screen becomes visible."""
568 from lilbee.runtime.splash import dismiss
570 dismiss()
571 self.refresh_model_bar()
572 # AUTO_FOCUS only fires once on initial mount. Re-entering the
573 # screen via view-nav needs an explicit focus restore. In INSERT
574 # mode we send focus to the chat input; in NORMAL mode we send
575 # focus to the chat log (the input is intentionally unfocusable
576 # so global bindings keep firing).
577 with contextlib.suppress(Exception):
578 if self._insert_mode:
579 self._enter_insert_mode()
580 else:
581 self.set_focus(self._chat_log)
582 typed, self.app.type_ahead = self.app.type_ahead, ""
583 if typed:
584 self._enter_insert_mode()
585 self._chat_input.insert(typed)
587 def _embedding_ready(self) -> bool:
588 """Quick check if the embedding model resolves (no network calls)."""
589 return is_model_available(cfg.embedding_model, get_services().provider)
591 def _on_settings_changed(self, payload: tuple[str, object]) -> None:
592 key, _value = payload
593 if key in {"chat_mode", "embedding_model"}:
594 self.refresh_model_bar()
596 def _on_chat_ready_changed(self, ready: bool) -> None:
597 """Paint or clear the no-model empty state as readiness changes."""
598 with contextlib.suppress(NoMatches):
599 self.query_one("#chat-welcome", ChatWelcome).set_no_model(not ready)
600 self._apply_input_busy_state()
602 def _enter_insert_mode(self) -> None:
603 """Switch to insert mode: focus input, update border style."""
604 self._insert_mode = True
605 self._chat_input.can_focus = True
606 self.set_focus(self._chat_input)
607 self._update_input_style()
609 def focus_prompt(self) -> None:
610 """Focus the chat input in INSERT mode so the next keys type a prompt."""
611 self._enter_insert_mode()
613 def default_focus_target(self) -> Widget:
614 """This mode's own focus target: the prompt in INSERT, the transcript in NORMAL."""
615 return self._chat_input if self._insert_mode else self._chat_log
617 def action_focus_model_bar(self) -> None:
618 """F6: put the cursor on the model strip. Left / Right walk it from there."""
619 self.query_one("#model-bar", ModelBar).focus_strip()
621 def action_enter_model_strip(self, direction: int) -> None:
622 """h / l and the arrows from NORMAL mode: step in from the matching side.
624 Only reached while focus is outside the bar. Once a role holds the
625 cursor the bar's own keys win, being nearer the focus.
626 """
627 self.query_one("#model-bar", ModelBar).focus_strip(direction)
629 def run_command(self, text: str) -> None:
630 """Dispatch *text* as a slash command, as if submitted from the prompt."""
631 if self._reject_submit_when_busy(text):
632 return
633 self._handle_slash(text)
635 def _update_input_style(self) -> None:
636 """Toggle input opacity and mode indicator based on current mode."""
637 # Lifecycle interleaves (an installed-but-swapped-away screen during
638 # app teardown) can invoke this before or after the input exists.
639 with contextlib.suppress(NoMatches):
640 inp = self._chat_input
641 if self._insert_mode:
642 inp.remove_class("normal-mode")
643 else:
644 inp.add_class("normal-mode")
645 self._update_mode_indicator()
647 def _update_mode_indicator(self) -> None:
648 """Update the ViewTabs mode text to reflect the current mode."""
649 with contextlib.suppress(NoMatches):
650 bar = self.query_one(ViewTabs)
651 bar.mode_text = msg.MODE_INSERT if self._insert_mode else msg.MODE_NORMAL
653 def on_key(self, event: events.Key) -> None:
654 """In INSERT mode, type a printable key into the prompt when focus sits elsewhere."""
655 inp = self._chat_input
656 if self._insert_mode and not inp.has_focus and event.is_printable and event.character:
657 self.set_focus(inp)
658 inp.insert(event.character)
659 event.prevent_default()
660 event.stop()
662 def action_insert_mode(self) -> None:
663 """i / a / o from NORMAL mode: back to INSERT with the prompt focused."""
664 self._enter_insert_mode()
666 def action_insert_or_send(self) -> None:
667 """Enter from NORMAL mode: back to INSERT, sending the draft the user Esc'd over."""
668 self._enter_insert_mode()
669 inp = self._chat_input
670 if inp.value.strip():
671 self._submit_draft(inp, inp.value)
673 def _leaves_normal_mode(self, action: str) -> bool:
674 """True when a NORMAL mode key should return to INSERT.
676 The prompt keeps its own keys. A focused Select or
677 model-strip member keeps Enter; asked of the bar rather than of a list of
678 widget types, so Enter on a mode pill switches the mode instead of
679 dropping to INSERT.
680 """
681 if self._insert_mode or self._chat_input.has_focus:
682 return False
683 if action == "insert_or_send":
684 return not (isinstance(self.focused, Select) or self._focus_in_model_bar())
685 return True
687 @on(events.DescendantFocus, "#chat-input")
688 def _on_chat_input_focused(self, event: events.DescendantFocus) -> None:
689 """Mark INSERT mode whenever the chat input takes focus.
691 With ``can_focus = False`` while in NORMAL mode, the only way the
692 input gains focus is via an explicit user action (click, or the
693 :meth:`_enter_insert_mode` helper that sets ``can_focus = True``
694 and focuses the input). Either path implies INSERT, so we sync
695 the screen mode here.
696 """
697 if not self._insert_mode:
698 self._enter_insert_mode()
700 @on(events.Click, "#chat-input")
701 def _on_chat_input_clicked(self, event: events.Click) -> None:
702 """Click on the chat input bar promotes to INSERT.
704 ``can_focus = False`` while in NORMAL mode swallows focus from the
705 click, so DescendantFocus never fires. Hook the Click directly so
706 a mouse user lands in INSERT just like a keystroke (i / a / o).
707 """
708 if not self._insert_mode:
709 self._enter_insert_mode()
710 event.stop()
712 def on_click(self, event: events.Click) -> None:
713 """Click outside the chat input bar drops back to NORMAL.
715 The chat-input click handler above promotes to INSERT; the
716 symmetric exit happens here so a mouse user gets the same
717 click-to-blur behavior they expect from any other text editor.
718 """
719 if not self._insert_mode:
720 return
721 if event.widget is None:
722 return
723 chat_input = self._chat_input
724 node: DOMNode | None = event.widget
725 while node is not None:
726 if node is chat_input:
727 return
728 node = node.parent
729 self.action_enter_normal_mode()
731 @on(ChatInput.Submitted, "#chat-input")
732 def _on_chat_submitted(self, event: ChatInput.Submitted) -> None:
733 if not self._insert_mode:
734 # Vim-style: Enter in normal mode flips back to insert without
735 # submitting whatever empty / stale text the input still holds.
736 self._enter_insert_mode()
737 return
738 self._submit_draft(event.chat_input, event.value)
740 def _submit_draft(self, chat_input: ChatInput, value: str) -> None:
741 """Send *value* as a command or message once the submit gate allows it."""
742 text = value.strip()
743 if not self._ready_to_submit(text):
744 return
745 chat_input.value = ""
746 self._input_history.append(text)
747 self._history_index = -1
749 if text.startswith("/"):
750 self._handle_slash(text)
751 return
752 self._send_message(text)
754 def _ready_to_submit(self, text: str) -> bool:
755 """Gate a submit: busy, consumed, empty, and keep-the-draft cases say no."""
756 if self._reject_submit_when_busy(text) or self._dismiss_overlay_on_submit() or not text:
757 return False
758 cmd = self._slash_name(text)
759 if cmd:
760 if cmd not in self._command_handlers:
761 # Keep the draft so a typo (or a stale leading slash) can be
762 # fixed in place instead of retyped.
763 self.notify(msg.CMD_UNKNOWN.format(cmd=cmd), severity="warning")
764 return False
765 return True
766 pending = self._pending_required_model_download()
767 if pending is not None:
768 # Keep the typed prompt in the input so the user can submit it
769 # again once the download finishes, instead of retyping it.
770 self.notify(
771 msg.CHAT_MODEL_DOWNLOADING.format(name=pending),
772 severity="warning",
773 timeout=5,
774 )
775 return False
776 return True
778 def _reject_submit_when_busy(self, text: str = "") -> bool:
779 """Toast and reject a submit while a swap is loading or a stream is in flight.
781 Returns True when the submit was rejected so the caller stops. The swap
782 check comes first: a prompt sent mid-swap would race a half-torn-down
783 fleet, so the user is asked to wait rather than cancel. Commands the
784 registry marks ``allowed_while_streaming`` pass the streaming check, so
785 /cancel and /model stay reachable during the turn they act on.
786 """
787 if self.swapping_model:
788 self.notify(msg.CHAT_MODEL_SWITCHING, severity="warning", timeout=3)
789 return True
790 if self.reloading_placement:
791 self.notify(msg.FLEET_RELOADING, severity="warning", timeout=3)
792 return True
793 if self.streaming and not runs_while_streaming(self._slash_name(text)):
794 self._notify_busy()
795 return True
796 return False
798 def _notify_busy(self) -> None:
799 """Say why a submit was refused: the turn is answering, or still stopping."""
800 text = msg.CHAT_STOPPING if self.stopping else msg.CHAT_BUSY
801 self.notify(text, severity="warning", timeout=3)
803 @staticmethod
804 def _slash_name(text: str) -> str:
805 """The command word of a slash submit, or "" when *text* is not one."""
806 return text.split()[0].lower() if text.startswith("/") else ""
808 def _pending_required_model_download(self) -> str | None:
809 """Return the in-flight download's name if it's for the configured chat or embedding model.
811 Covers the fresh-install case where the default ``cfg.chat_model``
812 points at a featured catalog ref whose file isn't on disk yet,
813 but a wizard-triggered download for it is queued or active.
814 """
815 task_bar = self.app.task_bar
816 for ref in (cfg.chat_model, cfg.embedding_model):
817 label = task_bar.downloading_label_for(ref)
818 if label is not None:
819 return label
820 return None
822 def _dismiss_overlay_on_submit(self) -> bool:
823 """Close the dropdown on Enter; consume only a bare slash, never a message.
825 Enter submits exactly what was typed. Tab and the arrow keys are the
826 completion gestures, and a previewed candidate is already in the input,
827 so a highlighted-but-unaccepted suggestion must never rewrite or swallow
828 a submission.
829 """
830 overlay = self._completion_overlay
831 if overlay.is_visible:
832 overlay.hide()
833 self._completion_origin = None
834 if self._chat_input.value.strip() == "/":
835 self._set_input("")
836 return True
837 return False
839 def _handle_slash(self, text: str) -> None:
840 """Dispatch slash commands via the per-instance handler registry."""
841 cmd = text.split()[0].lower()
842 args = text[len(cmd) :].strip()
843 handler = self._command_handlers.get(cmd)
844 if handler is not None:
845 handler(args)
846 else:
847 self.notify(msg.CMD_UNKNOWN.format(cmd=cmd), severity="warning")
849 def watch_streaming(self, streaming: bool) -> None:
850 if streaming:
851 self._enter_streaming_state()
852 else:
853 self._exit_streaming_state()
855 def watch_stopping(self, _stopping: bool) -> None:
856 self.refresh_bindings()
858 def _enter_streaming_state(self) -> None:
859 self.add_class("streaming")
860 self.refresh_bindings()
862 def _exit_streaming_state(self) -> None:
863 self.remove_class("streaming")
864 self.refresh_bindings()
865 if self._model_switch_queued:
866 # Cleared before the call: apply_model_change can re-enter here.
867 self._model_switch_queued = False
868 self.apply_model_change()
870 def _cmd_add(self, args: str) -> None:
871 from lilbee.app.ingest import source_label_taken
872 from lilbee.core.system import disk_path
874 if not args:
875 return
876 if self._sync_work_live():
877 self._notify_sync_refused()
878 return
879 if is_url(args):
880 self._cmd_crawl(args)
881 return
882 paths = _parse_add_paths(args)
883 missing = [p for p in paths if not p.exists()]
884 if missing:
885 self.notify(
886 msg.CMD_ADD_NOT_FOUND.format(path=", ".join(str(p) for p in missing)),
887 severity="error",
888 )
889 return
890 # A file add registers a root labeled by its basename. Prompt before
891 # overwriting only when that label is already taken by a different source
892 # (a live root elsewhere, or an owned file of that name); re-adding the
893 # same path is idempotent, and a directory is left to register_sources'
894 # own skipped notices rather than a duplicate-file prompt.
895 duplicates = [p for p in paths if p.is_file() and source_label_taken(disk_path(p).name, p)]
896 if duplicates:
897 self._prompt_overwrite(paths, duplicates)
898 return
899 self._submit_add(paths, force=False)
901 def _prompt_overwrite(self, paths: list[Path], duplicates: list[Path]) -> None:
902 """Ask to overwrite existing copies before re-syncing."""
903 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
905 names = ", ".join(p.name for p in duplicates)
907 def _on_confirm(confirmed: bool | None) -> None:
908 if not confirmed:
909 self.notify(msg.CMD_ADD_SKIPPED_DUPLICATE.format(name=names))
910 return
911 self._submit_add(paths, force=True)
913 self.app.push_screen(
914 ConfirmDialog(
915 msg.CMD_ADD_DUPLICATE_TITLE,
916 msg.CMD_ADD_DUPLICATE_MESSAGE.format(name=names),
917 ),
918 _on_confirm,
919 )
921 def _submit_add(self, paths: list[Path], *, force: bool) -> None:
922 """Spawn the add worker. Separated so overwrite confirm can reuse it."""
923 from lilbee.cli.tui.task_queue import TaskType
925 label = paths[0].name if len(paths) == 1 else f"{len(paths)} files"
927 def _target(reporter: ProgressReporter) -> None:
928 self._do_add(paths, reporter, force=force)
930 self._task_bar.start_task(f"Add {label}", TaskType.ADD, _target, indeterminate=True)
932 def _do_add(
933 self, paths: list[Path], reporter: ProgressReporter, *, force: bool = False
934 ) -> None:
935 """Register source roots and run sync. Called on worker thread with a reporter."""
936 from lilbee.app.ingest import register_sources
938 label = paths[0].name if len(paths) == 1 else f"{len(paths)} files"
939 reporter.update(0, f"Adding {label}...", indeterminate=True)
940 unregistered = AddRollback(paths=paths, at_sync=False)
941 try:
942 with leave_as_cancel(unregistered, reporter, reporter.cancelled_by_user):
943 reg_result = register_sources(paths, force=force)
944 except asyncio.CancelledError as exc:
945 raise TaskCancelledError(unregistered.message(msg.SYNC_CANCELLED_RESUME)) from exc
946 self._notify_registration(reg_result)
947 if not reg_result.reached_corpus:
948 return
949 # A source that took in another one stays: un-registering it orphans what it took.
950 revocable = reg_result.revocable
951 try:
952 with forget_unfinished_on_cancel(
953 paths, revocable, reporter, reporter.cancelled_by_user
954 ) as rollback:
955 self._sync_added(reg_result.registered, reporter)
956 except asyncio.CancelledError as exc:
957 raise TaskCancelledError(rollback.message(msg.SYNC_CANCELLED_RESUME)) from exc
958 except BaseException:
959 unregister_added_roots(revocable)
960 raise
962 def _notify_registration(self, reg_result: RegisterResult) -> None:
963 """Toast each source the add skipped, and a warning when nothing reached the corpus."""
964 for name in reg_result.name_taken:
965 call_from_thread(self, self.notify, msg.CMD_ADD_NAME_TAKEN.format(name=name))
966 if reg_result.tracked:
967 call_from_thread(
968 self, self.notify, msg.CMD_ADD_TRACKED.format(names=", ".join(reg_result.tracked))
969 )
970 if reg_result.overlapping_inside:
971 names = ", ".join(reg_result.overlapping_inside)
972 call_from_thread(self, self.notify, msg.CMD_ADD_OVERLAPPING.format(names=names))
973 if reg_result.containing:
974 names = ", ".join(reg_result.containing)
975 call_from_thread(self, self.notify, msg.CMD_ADD_CONTAINING.format(names=names))
976 for parent, children in reg_result.absorbed_into.items():
977 text = msg.CMD_ADD_ABSORBED.format(parent=parent, children=", ".join(children))
978 call_from_thread(self, self.notify, text)
979 if not reg_result.reached_corpus:
980 call_from_thread(self, self.notify, msg.CMD_ADD_NOTHING, severity="warning")
982 def _notify_unreadable_folders(self, result: SyncResult) -> None:
983 """Warn once per folder the sync could not open, worded as the CLI summary is."""
984 for folder in result.unreadable_folders:
985 call_from_thread(
986 self,
987 self.notify,
988 msg.SYNC_UNREADABLE_FOLDER.format(folder=folder),
989 severity="warning",
990 )
992 def _sync_added(self, registered: list[str], reporter: ProgressReporter) -> None:
993 """Sync after /add and report the result; raises when the add's own roots failed."""
994 from lilbee.data.ingest import sync
996 reporter.update(0, f"Added {len(registered)} source(s), syncing...", indeterminate=True)
997 sync_result = asyncio_loop.run(
998 sync(
999 quiet=True,
1000 on_progress=build_add_progress_callback(reporter),
1001 cancel=reporter,
1002 started_by="the lilbee TUI",
1003 )
1004 )
1005 self._notify_unreadable_folders(sync_result)
1006 if sync_result.failed:
1007 raise RuntimeError(msg.SYNC_FAILED_FILES.format(files=", ".join(sync_result.failed)))
1008 if sync_result.skipped:
1009 # Files yielding no text beside indexed siblings are a partial
1010 # success; only an add whose own roots contributed nothing failed.
1011 skipped_msg = msg.sync_skipped_message(sync_result, tui_log_path())
1012 if registered and not add_indexed_anything(registered, sync_result):
1013 raise RuntimeError(skipped_msg)
1014 call_from_thread(self, self.notify, skipped_msg, severity="warning")
1015 if sync_result.relocated:
1016 call_from_thread(
1017 self,
1018 self.notify,
1019 msg.CMD_ADD_RELOCATED.format(count=len(sync_result.relocated)),
1020 )
1021 call_from_thread(self, self.notify, msg.CMD_ADD_SUCCESS.format(count=len(registered)))
1023 def _cmd_cancel(self, _args: str) -> None:
1024 self._stop_turn()
1025 for worker in self.workers:
1026 if worker.group != _TURN_WORKER_GROUP:
1027 worker.cancel()
1028 self.notify(msg.CMD_CANCEL)
1030 def _cmd_clear(self, _args: str) -> None:
1031 self._reset_conversation()
1032 self.notify(msg.CMD_CLEAR)
1034 def _reset_conversation(self) -> None:
1035 """Stop the live turn, empty the log and history, and drop the active session.
1037 The current session is already persisted, so dropping the id just makes the
1038 next user turn open a fresh one.
1039 """
1040 self._stop_turn()
1041 self._chat_log.remove_children()
1042 self._active_question = None
1043 with self._history_lock:
1044 self._history.clear()
1045 # A new conversation inherits nothing, least of all the last one's
1046 # summary: carrying it would leak the old chat into the new prompt.
1047 self._summary = ""
1048 self._conversation_generation += 1
1049 self._session_id = None
1051 def _cmd_crawl(self, args: str) -> None:
1052 if not crawler_available():
1053 self.notify(CRAWLER_MISSING_MESSAGE, severity="error")
1054 return
1055 if not args:
1056 self._open_crawl_dialog()
1057 return
1058 parts = args.split()
1059 url = parts[0]
1060 if not is_url(url):
1061 url = f"https://{url}"
1062 try:
1063 require_valid_crawl_url(url)
1064 except ValueError as exc:
1065 self.notify(str(exc), severity="error")
1066 return
1067 try:
1068 depth, max_pages, include_subdomains, render_mode = self._parse_crawl_flags(parts[1:])
1069 except ValueError as exc:
1070 self.notify(str(exc), severity="error")
1071 return
1072 if (too_large := crawl_limit_error(depth, max_pages)) is not None:
1073 self.notify(too_large, severity="error")
1074 return
1075 self._start_crawl(
1076 url,
1077 depth,
1078 max_pages,
1079 include_subdomains=include_subdomains,
1080 render_mode=render_mode,
1081 )
1083 def _open_crawl_dialog(self) -> None:
1084 """Push the crawl modal and handle its result."""
1085 from lilbee.cli.tui.widgets.crawl_dialog import CrawlDialog, CrawlParams
1087 def _on_result(result: CrawlParams | None) -> None:
1088 if result is not None:
1089 self._start_crawl(
1090 result.url,
1091 result.depth,
1092 result.max_pages,
1093 render_mode=result.render_mode,
1094 login=result.login,
1095 )
1097 self.app.push_screen(CrawlDialog(), callback=_on_result)
1099 def _start_crawl(
1100 self,
1101 url: str,
1102 depth: int | None,
1103 max_pages: int | None,
1104 *,
1105 include_subdomains: bool = False,
1106 render_mode: CrawlRenderMode | None = None,
1107 login: CrawlLogin = NO_LOGIN,
1108 ) -> None:
1109 """Enqueue a crawl task and run it in the background.
1111 Bootstrap Chromium first via the controller helper, but only for a
1112 browser-mode crawl. HTTP mode needs no browser, so the SETUP task is
1113 skipped and the crawl starts immediately. An explicit ``render_mode``
1114 (from the dialog checkbox or ``--render``) is persisted so the choice
1115 sticks for the next crawl. ``login`` is the one typed for this crawl; it
1116 goes over the stored login to the seed host and is kept nowhere.
1117 """
1118 from lilbee.cli.tui.task_queue import TaskType
1120 try:
1121 login = crawl_login([url], login)
1122 except ValueError as exc:
1123 self.notify(msg.CRAWL_DIALOG_INVALID_LOGIN.format(error=exc), severity="error")
1124 return
1125 mode = render_mode if render_mode is not None else cfg.crawl_render_mode
1126 if render_mode is not None and render_mode is not cfg.crawl_render_mode:
1127 self._persist_crawl_render_mode(render_mode)
1129 shown = without_userinfo(url)
1131 def _kick_off_crawl() -> None:
1132 self._task_bar.start_task(
1133 msg.TASK_NAME_CRAWL.format(url=shown),
1134 TaskType.CRAWL,
1135 lambda reporter: self._do_crawl(
1136 url,
1137 depth,
1138 max_pages,
1139 reporter,
1140 include_subdomains=include_subdomains,
1141 render_mode=mode,
1142 login=login,
1143 ),
1144 on_success=lambda: call_from_thread(self, self.run_sync),
1145 )
1147 self.notify(msg.CMD_CRAWL_STARTED.format(url=shown))
1148 if mode is CrawlRenderMode.BROWSER:
1149 self._task_bar.ensure_chromium(_kick_off_crawl)
1150 else:
1151 _kick_off_crawl()
1153 def _persist_crawl_render_mode(self, render_mode: CrawlRenderMode) -> None:
1154 """Persist the chosen render mode so the dialog checkbox stays sticky."""
1155 from lilbee.app.settings import apply_settings_update
1157 try:
1158 apply_settings_update({"crawl_render_mode": render_mode.value})
1159 except (ValueError, OSError) as exc:
1160 log.warning("Could not persist crawl_render_mode: %s", exc)
1162 @staticmethod
1163 def _parse_crawl_flags(
1164 tokens: list[str],
1165 ) -> tuple[int | None, int | None, bool, CrawlRenderMode | None]:
1166 """Extract --depth, --max-pages, --include-subdomains, --render from tokens.
1168 Numeric flags return None when absent so the caller inherits
1169 crawl_and_save's unbounded-by-default semantics. A numeric flag whose
1170 value is not a whole number raises ValueError naming the flag. The boolean
1171 ``--include-subdomains`` flag defaults to False (exact-host scope).
1172 ``--render http|browser`` returns None when absent so the caller
1173 inherits ``cfg.crawl_render_mode``; an unrecognized value is ignored.
1174 """
1175 flag_map = {_CRAWL_FLAG_DEPTH: "depth", _CRAWL_FLAG_MAX_PAGES: "max_pages"}
1176 parsed: dict[str, int | None] = {"depth": None, "max_pages": None}
1177 include_subdomains = False
1178 render_mode: CrawlRenderMode | None = None
1179 i = 0
1180 while i < len(tokens):
1181 if tokens[i] == _CRAWL_FLAG_INCLUDE_SUBDOMAINS:
1182 include_subdomains = True
1183 i += 1
1184 continue
1185 if tokens[i] == _CRAWL_FLAG_RENDER and i + 1 < len(tokens):
1186 with contextlib.suppress(ValueError):
1187 render_mode = CrawlRenderMode(tokens[i + 1])
1188 i += 2
1189 continue
1190 key = flag_map.get(tokens[i])
1191 if key and i + 1 < len(tokens):
1192 try:
1193 parsed[key] = int(tokens[i + 1])
1194 except ValueError:
1195 message = msg.CMD_CRAWL_FLAG_NOT_A_NUMBER.format(flag=tokens[i])
1196 raise ValueError(message) from None
1197 i += 2
1198 else:
1199 i += 1
1200 return parsed["depth"], parsed["max_pages"], include_subdomains, render_mode
1202 def _do_crawl(
1203 self,
1204 url: str,
1205 depth: int | None,
1206 max_pages: int | None,
1207 reporter: ProgressReporter,
1208 *,
1209 include_subdomains: bool = False,
1210 render_mode: CrawlRenderMode | None = None,
1211 login: CrawlLogin = NO_LOGIN,
1212 ) -> None:
1213 """Crawl body. Runs on worker thread; reporter handles progress + cancel."""
1214 from lilbee.crawler import crawl_and_save
1215 from lilbee.runtime.progress import CrawlPageEvent, CrawlPageFailedEvent, SetupProgressEvent
1217 shown = without_userinfo(url)
1218 reporter.update(0, msg.CMD_CRAWL_STARTED.format(url=shown))
1219 failures: list[str] = []
1220 setup_labels = {BROWSER_SETUP_COMPONENT: msg.SETUP_BROWSER_WARM_UP}
1222 def on_progress(event_type: EventType, data: ProgressEvent) -> None:
1223 if event_type == EventType.SETUP_START and isinstance(data, SetupStartEvent):
1224 reporter.update(0, setup_labels.get(data.component, msg.SETUP_CHROMIUM_NAME))
1225 elif event_type == EventType.SETUP_PROGRESS and isinstance(data, SetupProgressEvent):
1226 if data.total_bytes:
1227 pct = int(data.downloaded_bytes * 100 / data.total_bytes)
1228 detail = msg.SETUP_CHROMIUM_DETAIL.format(
1229 done=data.downloaded_bytes // (1024 * 1024),
1230 total=data.total_bytes // (1024 * 1024),
1231 )
1232 else:
1233 pct = 0
1234 detail = msg.SETUP_CHROMIUM_DETAIL_UNKNOWN.format(
1235 done=data.downloaded_bytes // (1024 * 1024),
1236 )
1237 reporter.update(pct, detail)
1238 elif event_type == EventType.CRAWL_PAGE and isinstance(data, CrawlPageEvent):
1239 # Discovery hasn't resolved a sitemap yet (data.total <= 0):
1240 # show the indeterminate spinner with a count, not a parked
1241 # 50% bar that looks frozen. Switch to a determinate bar as
1242 # soon as the total is known.
1243 if data.total > 0:
1244 pct = int(data.current * 100 / data.total)
1245 reporter.update(
1246 pct,
1247 msg.CMD_CRAWL_PAGE.format(
1248 current=data.current, total=data.total, url=data.url
1249 ),
1250 indeterminate=False,
1251 )
1252 else: # pragma: no cover - live crawl without sitemap
1253 reporter.update(
1254 0,
1255 msg.CMD_CRAWL_PAGE_INDETERMINATE.format(current=data.current, url=data.url),
1256 indeterminate=True,
1257 )
1258 elif event_type == EventType.CRAWL_PAGE_FAILED and isinstance(
1259 data, CrawlPageFailedEvent
1260 ):
1261 failures.append(data.reason)
1263 paths = asyncio_loop.run(
1264 crawl_and_save(
1265 url,
1266 depth=depth,
1267 max_pages=max_pages,
1268 on_progress=on_progress,
1269 cancel=reporter,
1270 include_subdomains=include_subdomains,
1271 render_mode=render_mode,
1272 login=login,
1273 )
1274 )
1275 reporter.check_cancelled()
1276 call_from_thread(
1277 self, self.notify, msg.CMD_CRAWL_SUCCESS.format(count=len(paths), url=shown)
1278 )
1279 if failures:
1280 call_from_thread(
1281 self,
1282 self.notify,
1283 msg.CMD_CRAWL_PAGES_FAILED.format(count=len(failures), reason=failures[-1]),
1284 severity="warning",
1285 )
1287 def _cmd_catalog(self, _args: str) -> None:
1288 # switch_view already installs and navigates to the managed Catalog view;
1289 # a push_screen on top would stack a second, orphaned CatalogScreen.
1290 self.app.switch_view(msg.CATALOG_VIEW)
1292 def _cmd_prune_ignored(self, args: str) -> None:
1293 """Sync with pruning on, dropping indexed documents the patterns now exclude."""
1294 del args
1295 self.run_sync(prune_ignored=True)
1297 def _cmd_delete(self, args: str) -> None:
1298 """Run /delete in a worker so the chat screen stays interactive."""
1299 self._cmd_delete_worker(args.strip())
1301 @work(thread=True, name="chat_cmd_delete", exit_on_error=False)
1302 def _cmd_delete_worker(self, name: str) -> None:
1303 """Validate and execute /delete off the UI thread; notify back via dispatch."""
1304 try:
1305 sources = get_services().store.get_sources()
1306 except Exception:
1307 log.debug("Failed to list documents for /delete", exc_info=True)
1308 call_from_thread(self, self.notify, msg.CMD_DELETE_READ_FAILED, severity="error")
1309 return
1311 known = set(removable_names([s.get("filename", s.get("source", "?")) for s in sources]))
1312 if not known:
1313 call_from_thread(self, self.notify, msg.CMD_DELETE_NO_DOCS, severity="warning")
1314 return
1316 if not name:
1317 names = ", ".join(strip_control_characters(n) for n in sorted(known))
1318 usage = msg.CMD_DELETE_USAGE.format(names=names)
1319 call_from_thread(self, self.notify, usage)
1320 return
1322 if name not in known:
1323 message = msg.CMD_DELETE_NOT_FOUND.format(name=name)
1324 suggestion = _closest_source(name, known)
1325 if suggestion is not None:
1326 hint = msg.CMD_DELETE_SUGGESTION.format(name=strip_control_characters(suggestion))
1327 message = f"{message}. {hint}"
1328 call_from_thread(self, self.notify, message, severity="error")
1329 return
1331 try:
1332 remove_documents_durably([name])
1333 except LIBRARY_REFUSALS as exc:
1334 call_from_thread(self, self.notify, str(exc), severity="error")
1335 return
1336 call_from_thread(self, self.notify, msg.CMD_DELETE_SUCCESS.format(name=name))
1338 def _cmd_export(self, args: str) -> None:
1339 """Enqueue /export as a task so progress shows in the task bar."""
1340 path = args.strip()
1341 if not path:
1342 self.notify(msg.CMD_EXPORT_USAGE, severity="warning")
1343 return
1344 from lilbee.cli.tui.task_queue import TaskType
1346 def _target(reporter: ProgressReporter) -> None:
1347 self._do_export(path, reporter)
1349 name = msg.TASK_NAME_EXPORT.format(file=Path(path).name)
1350 self._task_bar.start_task(name, TaskType.EXPORT, _target, indeterminate=True)
1352 def _do_export(self, raw_path: str, reporter: ProgressReporter) -> None:
1353 """Export body. Runs on the task worker thread."""
1354 from lilbee.app.dataset import DatasetError, export_to_path
1356 output = Path(raw_path).expanduser()
1357 reporter.update(0, msg.EXPORT_STATUS_RUNNING, indeterminate=True)
1358 try:
1359 summary = export_to_path(output, "", None)
1360 # OSError: output path unwritable or library unreadable
1361 except (DatasetError, OSError) as exc:
1362 call_from_thread(self, self.notify, str(exc), severity="error")
1363 raise RuntimeError(str(exc)) from exc
1364 call_from_thread(
1365 self,
1366 self.notify,
1367 msg.CMD_EXPORT_SUCCESS.format(pages=summary.pages, output=output),
1368 )
1370 def _cmd_import(self, args: str) -> None:
1371 """Enqueue /import as a task so re-embedding progress shows in the task bar."""
1372 path = args.strip()
1373 if not path:
1374 self.notify(msg.CMD_IMPORT_USAGE, severity="warning")
1375 return
1376 if self._sync_work_live():
1377 self._notify_sync_refused()
1378 return
1379 from lilbee.cli.tui.task_queue import TaskType
1381 def _target(reporter: ProgressReporter) -> None:
1382 try:
1383 self._do_import(path, reporter)
1384 finally:
1385 self._task_bar.start_detect_pending()
1387 name = msg.TASK_NAME_IMPORT.format(file=Path(path).name)
1388 self._task_bar.start_task(name, TaskType.IMPORT, _target)
1390 def _do_import(self, raw_path: str, reporter: ProgressReporter) -> None:
1391 """Import body. Runs on the task worker thread."""
1392 from lilbee.app.dataset import DatasetError, import_from_path
1394 reporter.update(0, msg.IMPORT_STATUS_LOADING, indeterminate=True)
1395 try:
1396 summary = asyncio_loop.run(
1397 import_from_path(
1398 Path(raw_path).expanduser(),
1399 "",
1400 on_progress=build_import_progress_callback(reporter),
1401 )
1402 )
1403 except DatasetError as exc:
1404 call_from_thread(self, self.notify, str(exc), severity="error")
1405 raise RuntimeError(str(exc)) from exc
1406 call_from_thread(
1407 self,
1408 self.notify,
1409 msg.CMD_IMPORT_SUCCESS.format(
1410 sources=len(summary.sources), pages=summary.pages, chunks=summary.chunks
1411 ),
1412 )
1414 def _cmd_help(self, _args: str) -> None:
1415 self.action_show_command_catalog()
1417 def action_show_command_catalog(self) -> None:
1418 """Push the slash-command catalog modal; selected name is inserted into the input."""
1419 self.app.push_screen(SlashCommandCatalog(), self._on_catalog_pick)
1421 def insert_slash_command(self, name: str) -> None:
1422 """Drop ``name + ' '`` into the chat input and focus it for argument entry."""
1423 self._enter_insert_mode()
1424 inp = self._chat_input
1425 inp.value = f"{name} "
1426 inp.action_end()
1428 def _on_catalog_pick(self, name: str | None) -> None:
1429 if name is None:
1430 return
1431 self.insert_slash_command(name)
1433 def _cmd_login(self, args: str) -> None:
1434 token = args.strip()
1435 if not token:
1436 import webbrowser
1438 webbrowser.open("https://huggingface.co/settings/tokens")
1439 self.notify(msg.CHAT_LOGIN_PROMPT)
1440 return
1441 self._run_hf_login(token)
1443 @work(thread=True)
1444 def _run_hf_login(self, token: str) -> None:
1445 try:
1446 from huggingface_hub import login
1448 login(token=token, add_to_git_credential=False)
1449 call_from_thread(self, self.notify, msg.CHAT_LOGGED_IN)
1450 except Exception as exc:
1451 log.warning("HuggingFace login failed", exc_info=True)
1452 call_from_thread(
1453 self, self.notify, msg.CHAT_LOGIN_FAILED.format(error=exc), severity="error"
1454 )
1456 def _cmd_model(self, args: str) -> None:
1457 if args:
1458 from lilbee.catalog.formatting import display_label_for_ref
1460 apply_active_model(self.app, "chat_model", args)
1461 self.app.title = msg.app_title(cfg.chat_model)
1462 self.notify(
1463 msg.CMD_MODEL_SET.format(name=display_label_for_ref(cfg.chat_model)),
1464 )
1465 self.apply_model_change()
1466 self.refresh_model_bar()
1467 else:
1468 from lilbee.cli.tui.screens.catalog import CatalogScreen
1470 self.app.push_screen(CatalogScreen())
1472 def _cmd_quit(self, _args: str) -> None:
1473 self.app.exit()
1475 def _cmd_remove(self, args: str) -> None:
1476 name = args.strip()
1477 if not name:
1478 self.notify(msg.CMD_REMOVE_USAGE, severity="warning")
1479 return
1480 self._run_remove_model(name)
1482 @work(thread=True)
1483 def _run_remove_model(self, name: str) -> None:
1484 mgr = get_services().model_manager
1485 try:
1486 # The remove call is the only check: a pre-check would answer
1487 # "installed?" a second way and disagree with `lilbee model rm`.
1488 if mgr.remove(name):
1489 call_from_thread(self, self.notify, msg.CMD_REMOVE_SUCCESS.format(name=name))
1490 else:
1491 call_from_thread(
1492 self, self.notify, msg.CMD_REMOVE_NOT_FOUND.format(name=name), severity="error"
1493 )
1494 except Exception:
1495 log.warning("Remove failed for %s", name, exc_info=True)
1496 call_from_thread(
1497 self, self.notify, msg.CMD_REMOVE_FAILED.format(name=name), severity="error"
1498 )
1500 def _cmd_rebuild(self, _args: str) -> None:
1501 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
1503 def _on_confirm(confirmed: bool | None) -> None:
1504 if not confirmed:
1505 return
1506 self.run_sync(force_rebuild=True)
1508 self.app.push_screen(
1509 ConfirmDialog(msg.CMD_REBUILD_CONFIRM_TITLE, msg.CMD_REBUILD_CONFIRM_MESSAGE),
1510 _on_confirm,
1511 )
1513 def _cmd_reset(self, args: str) -> None:
1514 self.request_reset()
1516 def request_reset(self) -> None:
1517 """Public entry for the confirm-then-wipe flow (shared by /reset and the
1518 command palette), so callers don't reach into a private slash handler."""
1519 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
1521 def _on_confirm(confirmed: bool | None) -> None:
1522 if not confirmed:
1523 return
1524 from lilbee.app.reset import perform_reset
1526 try:
1527 result = perform_reset()
1528 except ResetRefusedError as exc:
1529 self.notify(str(exc), severity="warning")
1530 return
1531 except Exception as exc:
1532 log.warning("Reset failed", exc_info=True)
1533 self.notify(msg.CMD_RESET_FAILED.format(error=exc), severity="error")
1534 return
1536 # Reopen LanceDB against the now-empty data dir; keep providers loaded.
1537 reset_store()
1539 if result.skipped:
1540 self.notify(
1541 msg.CMD_RESET_PARTIAL.format(skipped=len(result.skipped)),
1542 severity="warning",
1543 )
1544 else:
1545 self.notify(msg.CMD_RESET_SUCCESS)
1547 self.app.push_screen(
1548 ConfirmDialog(msg.CMD_RESET_CONFIRM_TITLE, msg.CMD_RESET_CONFIRM_MESSAGE),
1549 _on_confirm,
1550 )
1552 def _cmd_set(self, args: str) -> None:
1553 if not args:
1554 return
1555 parts = args.split(None, 1)
1556 key = parts[0]
1557 value = parts[1] if len(parts) > 1 else ""
1559 if key not in SETTINGS_MAP:
1560 self.notify(msg.CMD_SET_UNKNOWN.format(key=key), severity="warning")
1561 return
1563 defn = SETTINGS_MAP[key]
1564 if not defn.writable and key not in CLEARABLE_MODEL_FIELDS:
1565 self.notify(msg.CMD_SET_READONLY.format(key=key), severity="warning")
1566 return
1567 clears = defn.nullable and value.lower() in ("none", "null", "")
1568 try:
1569 parsed: object
1570 if key in CLEARABLE_MODEL_FIELDS:
1571 parsed = "" if clears else value
1572 elif defn.type is bool:
1573 # The text goes to the shared write, which refuses what it cannot read.
1574 parsed = value
1575 elif clears:
1576 parsed = None
1577 else:
1578 if defn.choices and value not in defn.choices:
1579 self.notify(
1580 msg.CMD_SET_CHOICES.format(key=key, choices=", ".join(defn.choices)),
1581 severity="error",
1582 )
1583 return
1584 try:
1585 parsed = list_setting_value(value) if defn.type is list else defn.type(value)
1586 except (ValueError, TypeError):
1587 self.notify(
1588 msg.CMD_SET_TYPE_HINT.format(key=key, kind=_setting_type_hint(defn.type)),
1589 severity="error",
1590 )
1591 return
1592 # Route through set_setting so settings_changed_signal subscribers
1593 # (model bar, scope chip, status bar) refresh.
1594 self._save_and_echo(key, defn, parsed)
1595 except (ValueError, TypeError) as exc:
1596 reason = _refusal_text(exc)
1597 self.notify(msg.CMD_SET_INVALID.format(key=key, error=reason), severity="error")
1599 def _save_and_echo(self, key: str, defn: SettingDef, parsed: object) -> None:
1600 """Save *parsed*, then echo it. A failed write toasts in set_setting and echoes nothing."""
1601 if not self.app.set_setting(key, parsed):
1602 return
1603 shown = _set_echo(key, parsed, secret=defn.secret, show_stored=defn.type in (bool, list))
1604 self.notify(msg.CMD_SET_SUCCESS.format(key=key, value=shown))
1606 def _cmd_settings(self, _args: str) -> None:
1607 self.app.switch_view("Settings")
1609 def _cmd_profile(self, args: str) -> None:
1610 name = args.strip()
1611 if not name:
1612 self.app.open_profile_tab()
1613 return
1614 start_profile_switch(self.app, self, name, on_close=lambda: None)
1616 def _cmd_analyze(self, args: str) -> None:
1617 arg = args.strip()
1618 words: dict[str, Callable[[], None]] = {
1619 ANALYZE_OFF_ARG: self._hide_tip,
1620 ANALYZE_REPORT_ARG: lambda: open_report(self.app),
1621 }
1622 word = words.get(arg.lower())
1623 if word is not None:
1624 word()
1625 return
1626 directory = _whole_path(arg) if arg else None
1627 if directory is not None and not directory.is_dir():
1628 self.notify(msg.ANALYZE_NOT_A_FOLDER.format(path=directory), severity="error")
1629 return
1630 start_analysis(self.app, directory)
1632 def _hide_tip(self) -> None:
1633 run_profile_op(self, lambda: hide_tip(cfg.data_root), self._tip_hidden)
1635 def _tip_hidden(self, _result: None) -> None:
1636 self._arg_hint.refresh_tip()
1637 self.notify(msg.ANALYZE_TIP_HIDDEN)
1639 def _cmd_remember(self, args: str) -> None:
1640 """Run /remember in a worker so embedding the text never blocks the UI."""
1641 self._cmd_remember_worker(args)
1643 @work(thread=True, name="chat_cmd_remember", exit_on_error=False)
1644 def _cmd_remember_worker(self, raw: str) -> None:
1645 """Store the memory off the UI thread; notify the outcome back on it."""
1646 outcome = remember_from_input(raw)
1647 call_from_thread(self, self.notify, outcome.message, severity=outcome.severity)
1649 def _cmd_memories(self, _args: str) -> None:
1650 from lilbee.cli.tui.screens.memories import MemoriesScreen
1652 self.app.push_screen(MemoriesScreen())
1654 def _cmd_status(self, _args: str) -> None:
1655 self.app.switch_view("Status")
1657 def _cmd_theme(self, args: str) -> None:
1658 if not args:
1659 # Land in the prompt with the dropdown listing every theme.
1660 self.insert_slash_command("/theme")
1661 return
1662 if args not in DARK_THEMES:
1663 self.notify(
1664 msg.CMD_THEME_UNKNOWN.format(name=args, names=", ".join(DARK_THEMES)),
1665 severity="warning",
1666 )
1667 return
1668 self.app.set_theme(args)
1669 self.notify(msg.THEME_SET.format(name=args))
1671 def _cmd_version(self, _args: str) -> None:
1672 self.notify(msg.CHAT_VERSION.format(version=get_version()))
1674 def _cmd_wiki(self, _args: str) -> None:
1675 if not cfg.wiki:
1676 self.notify(msg.CMD_WIKI_DISABLED, severity="warning")
1677 return
1678 self.app.switch_view("Wiki")
1680 def _cmd_sessions(self, _args: str) -> None:
1681 self.app.call_later(self.app.action_toggle_sessions)
1683 def _read_active_session(self, no_session: str, gone: str) -> Session | None:
1684 """The active session as the store holds it, or None after telling the user why not."""
1685 if not cfg.sessions_enabled:
1686 self.app.notify_sessions_disabled()
1687 return None
1688 if self._session_id is None:
1689 self.notify(no_session, severity="warning")
1690 return None
1691 try:
1692 return get_services().session_store.get(self._session_id)
1693 except SessionNotFoundError:
1694 self.notify(gone, severity="warning")
1695 return None
1697 def _cmd_fork(self, _args: str) -> None:
1698 """Open the fork picker over the answers of the current session as the store holds it."""
1699 source = self._read_active_session(msg.FORK_NO_SESSION, msg.FORK_SESSION_GONE)
1700 if source is None:
1701 return
1702 points = fork_points(source.messages)
1703 if not points:
1704 self.notify(msg.FORK_NO_ANSWER, severity="warning")
1705 return
1706 self.app.push_screen(ForkPicker(points), partial(self._on_fork_picked, source))
1708 def _cmd_export_chat(self, args: str) -> None:
1709 """Write the current session as markdown to *args*, or to the working directory."""
1710 session = self._read_active_session(
1711 msg.EXPORT_CHAT_NO_SESSION, msg.EXPORT_CHAT_SESSION_GONE
1712 )
1713 if session is None:
1714 return
1715 try:
1716 path = write_session_markdown(session, args.strip() or ".")
1717 except OSError as exc:
1718 self.notify(msg.EXPORT_CHAT_FAILED.format(error=exc), severity="error")
1719 return
1720 self.notify(msg.EXPORT_CHAT_DONE.format(path=path))
1722 def _on_fork_picked(self, source: Session, message_count: int | None) -> None:
1723 """Fork *source* after the picked answer and switch to the fork with an empty input."""
1724 if message_count is None:
1725 return
1726 try:
1727 fork_id = get_services().session_store.fork(
1728 source.meta.id, message_count=message_count, origin=SessionOrigin.TUI
1729 )
1730 except SessionNotFoundError:
1731 self.notify(msg.FORK_SESSION_GONE, severity="warning")
1732 return
1733 except SessionOwnershipError as exc:
1734 self.notify(msg.FORK_FAILED.format(error=exc), severity="warning")
1735 return
1736 fork = self._load_session(fork_id)
1737 self.notify(msg.FORK_DONE.format(title=strip_control_characters(fork.meta.title)))
1738 self._set_input("")
1739 self._enter_insert_mode()
1741 def _send_message(self, text: str) -> None:
1742 """Send a user message and stream the response, unless a turn is live."""
1743 from textual.css.query import NoMatches
1745 if self.streaming:
1746 self._notify_busy()
1747 return
1748 # A terminal without focus events never tells the app that another shell changed
1749 # config.toml, so each question checks the file before it reads cfg.
1750 self.app.adopt_external_settings()
1751 log = self._chat_log
1752 with contextlib.suppress(NoMatches):
1753 log.query_one("#chat-welcome", ChatWelcome).remove()
1754 question = UserMessage(text)
1755 log.mount(question)
1756 self._active_question = question
1758 # The assistant bubble owns its own ThinkingHeader animator until
1759 # the first reasoning or content token swaps it out.
1760 assistant_msg = AssistantMessage()
1761 log.mount(assistant_msg)
1762 # A fresh turn always follows its own answer, even if the user had
1763 # scrolled up during the previous response and released the anchor.
1764 log.anchor()
1766 with self._history_lock:
1767 self._history.append({"role": "user", "content": text})
1768 self._persist_user_turn(text)
1769 turn = _Turn(text, assistant_msg, self._conversation_generation, self._session_id)
1770 self._turn = turn
1771 self.streaming = True
1772 self._stream_response(turn, self._current_chunk_type())
1774 def _current_scope_value(self) -> str:
1775 """The ScopeChip's selection, or "both" when the chip isn't mounted."""
1776 from textual.css.query import NoMatches
1778 from lilbee.cli.tui.widgets.scope_chip import ScopeChip
1780 try:
1781 chip = self.query_one("#scope-chip", ScopeChip)
1782 except NoMatches:
1783 return SearchScope.BOTH.value
1784 return chip.scope
1786 def _current_chunk_type(self) -> ChunkType | None:
1787 """Translate the ScopeChip selection into a ``chunk_type`` arg.
1789 Returns ``None`` for "both" (no filter) and the raw/wiki ``ChunkType``
1790 otherwise.
1791 """
1792 return scope_to_chunk_type(self._current_scope_value())
1794 def _open_session(self, store: SessionStore, first_text: str) -> str:
1795 """Create the active session, auto-title it, and return its id."""
1796 session_id = store.create(model_ref=cfg.chat_model, scope=self._current_scope_value())
1797 store.set_title(session_id, derive_title(first_text), TitleSource.AUTO)
1798 self._session_id = session_id
1799 return session_id
1801 def _persist_user_turn(self, text: str) -> None:
1802 """Open a session on the first turn (auto-titled), then append the message."""
1803 if not cfg.sessions_enabled:
1804 # Sessions turned off: the conversation stays live in memory but is
1805 # never written to disk. _session_id stays None, so the assistant
1806 # turn's persist is a no-op too.
1807 return
1808 store = get_services().session_store
1809 session_id = self._session_id or self._open_session(store, text)
1810 message = SessionMessage(role=MessageRole.USER, content=text)
1811 try:
1812 store.add_message(session_id, message, surface=SessionOrigin.TUI)
1813 except SessionNotFoundError:
1814 # The active session was deleted mid-chat (e.g. from the drawer);
1815 # open a fresh one so auto-save keeps working instead of crashing.
1816 store.add_message(self._open_session(store, text), message, surface=SessionOrigin.TUI)
1818 def _save_assistant_turn(
1819 self, session_id: str | None, content: str, sources: list[SourceRef]
1820 ) -> None:
1821 """Persist the assistant turn, telling the user when the save fails. Worker thread."""
1822 try:
1823 self._persist_assistant_turn(session_id, content, sources)
1824 except (OSError, SessionOwnershipError) as exc:
1825 log.warning("Could not save the assistant turn", exc_info=True)
1826 call_from_thread(
1827 self, self.notify, msg.SESSIONS_SAVE_FAILED.format(error=exc), severity="error"
1828 )
1830 def _persist_assistant_turn(
1831 self, session_id: str | None, content: str, sources: list[SourceRef]
1832 ) -> None:
1833 """Append the assistant turn and its Sources list to *session_id*, the session
1834 its turn started in."""
1835 if session_id is None or not cfg.sessions_enabled:
1836 # Sessions switched off mid-conversation: the id outlives the
1837 # setting, so the toggle has to be re-checked here rather than
1838 # relying on _persist_user_turn having left the id unset.
1839 return
1840 # A concurrent delete of the active session must not crash the worker.
1841 with contextlib.suppress(SessionNotFoundError):
1842 get_services().session_store.add_message(
1843 session_id,
1844 message_with_refs(
1845 MessageRole.ASSISTANT,
1846 content,
1847 tuple(StoredSourceRef(r.source, r.citation_number, r.locator) for r in sources),
1848 ),
1849 surface=SessionOrigin.TUI,
1850 )
1852 def resume_session(self, session_id: str) -> None:
1853 """Load a saved session into the chat view, make it the active one, and focus the prompt."""
1854 session = self._load_session(session_id)
1855 self.focus_prompt()
1856 self.notify(msg.SESSIONS_RESUMED.format(title=strip_control_characters(session.meta.title)))
1858 def _load_session(self, session_id: str) -> Session:
1859 """Replace the conversation with a saved session's transcript and summary."""
1860 session = get_services().session_store.get(session_id)
1861 self._reset_conversation()
1862 self._session_id = session_id
1863 for message in session.messages:
1864 self._render_restored_message(message)
1865 # Load the whole transcript and the summary it was compacted with. What
1866 # does not fit is folded into the summary by _compact_history on the next
1867 # turn, off the UI thread; windowing it away here would silently lose the
1868 # turns between the stored summary and the window, which is precisely
1869 # what a resumed conversation must not do.
1870 loaded: list[ChatMessage] = [
1871 {"role": message.role.value, "content": message.content} for message in session.messages
1872 ]
1873 with self._history_lock:
1874 self._history = loaded
1875 self._summary = session.summary
1876 self._restore_session_model(session.meta.model_ref)
1877 self._refresh_context_usage()
1878 self._chat_log.scroll_end(animate=False)
1879 return session
1881 def _restore_session_model(self, model_ref: str) -> None:
1882 """Switch to the session's chat model if it is still installed.
1884 A conversation records the model it used, but that model may have been
1885 deleted since. Restoring a missing ref would be rejected by the model
1886 boundary with a scary error, so only switch when the model is installed;
1887 otherwise keep the current model and say the original is gone.
1888 """
1889 if not model_ref or model_ref == cfg.chat_model:
1890 return
1891 if get_services().registry.is_installed(model_ref):
1892 apply_active_model(self.app, "chat_model", model_ref)
1893 else:
1894 self.notify(
1895 msg.SESSIONS_MODEL_UNAVAILABLE.format(model=model_ref, current=cfg.chat_model),
1896 severity="warning",
1897 )
1899 @property
1900 def session_id(self) -> str | None:
1901 """The saved session this conversation persists to, or None before the first turn."""
1902 return self._session_id
1904 def start_new_conversation(self) -> None:
1905 """Clear the conversation, open a fresh session on the next turn, and focus the prompt."""
1906 self._reset_conversation()
1907 self.focus_prompt()
1908 self.notify(msg.SESSIONS_NEW)
1910 def _render_restored_message(self, message: SessionMessage) -> None:
1911 """Mount a completed message widget for a resumed turn."""
1912 log = self._chat_log
1913 if message.role == MessageRole.USER:
1914 log.mount(UserMessage(strip_control_characters(message.content, keep_lines=True)))
1915 return
1916 # Constructed complete, not appended-to after mounting: mount() is async,
1917 # so append_content/finish would both no-op against a content widget that
1918 # compose has not built yet, and the answer would render empty.
1919 # An older TUI turn's Sources list is read from its content into refs.
1920 text, sources = message_sources(message)
1921 log.mount(
1922 AssistantMessage(
1923 content=strip_control_characters(text, keep_lines=True), sources=sources
1924 )
1925 )
1927 @work(thread=True, group=_TURN_WORKER_GROUP)
1928 def _stream_response(self, turn: _Turn, chunk_type: ChunkType | None) -> None:
1929 """Run *turn* on a background thread and end it, whatever happens."""
1930 try:
1931 self._do_stream_response(turn, chunk_type)
1932 finally:
1933 post_from_thread(self, _TurnEnded(turn))
1935 def _do_stream_response(self, turn: _Turn, chunk_type: ChunkType | None) -> None:
1936 """Stream the answer to *turn*'s question and save it. Worker thread."""
1937 response_parts: list[str] = []
1938 stream: Any = None
1939 try:
1940 if not self._await_chat_engine(turn) or self._turn_stopped(turn):
1941 return
1942 self._compact_history(turn.session_id, turn.generation)
1943 if self._turn_stopped(turn):
1944 return
1945 with self._history_lock:
1946 # [:-1] drops the question, which ask_stream takes separately.
1947 recent = self._history[:-1]
1948 summary = self._summary
1949 history_snapshot = prompt_history(recent, summary, max_tokens=self._history_budget())
1950 stream = get_services().searcher.ask_stream(
1951 turn.question, history=history_snapshot, chunk_type=chunk_type
1952 )
1953 self._consume_stream(stream, turn, response_parts)
1954 except EmbeddingModelMismatchError as exc:
1955 with contextlib.suppress(Exception):
1956 call_from_thread(self, self._on_embedding_mismatch, exc, turn.question, turn.widget)
1957 except Exception as exc:
1958 log.debug("Stream error", exc_info=True)
1959 # A stop severs the transport, which surfaces here as a stream error;
1960 # the turn's end already says it was cancelled.
1961 if not self._turn_stopped(turn):
1962 with contextlib.suppress(Exception):
1963 call_from_thread(self, turn.widget.append_content, _stream_error_text(exc))
1964 finally:
1965 close_stream(stream)
1966 turn.answer = "".join(response_parts)
1967 self._save_turn(turn)
1969 @staticmethod
1970 def _turn_stopped(turn: _Turn) -> bool:
1971 """Whether *turn* was asked to stop, or Textual is tearing its worker down."""
1972 if turn.stop.is_set():
1973 return True
1974 try:
1975 return _get_worker().is_cancelled
1976 except NoActiveWorker:
1977 return False
1979 def _await_chat_engine(self, turn: _Turn) -> bool:
1980 """Hold the stream until the engine can serve, painting the load into *turn*'s bubble.
1982 The default lifecycle loads the engine on demand, so the first prompt of
1983 a session usually lands here: the answer bubble's thinking row carries the
1984 live load phase instead of the input locking up. Worker thread. Returns
1985 False once the turn was stopped or the load failed, with any failure
1986 already rendered into the bubble.
1987 """
1988 from lilbee.app.placement import (
1989 chat_engine_ready,
1990 chat_warm_error,
1991 request_engine_warm,
1992 wait_chat_ready,
1993 )
1995 # Build the container if nothing holds it (a settings change resets it);
1996 # readiness is probed via peek_services, which never builds, so without
1997 # this a prompt sent into the gap would report a dead engine instead of
1998 # lazily rebuilding the way ask_stream always has.
1999 get_services()
2000 if chat_engine_ready():
2001 return True
2002 # A failed boot warm leaves nothing in flight; this restarts the engine
2003 # so the prompt waits out a fresh load instead of bouncing.
2004 request_engine_warm()
2005 self._show_warm_tip_once()
2006 widget = turn.widget
2008 def _paint(snapshot: WarmProgress) -> None:
2009 with contextlib.suppress(Exception):
2010 call_from_thread(self, widget.set_thinking_status, _engine_status_text(snapshot))
2012 # Label the wait before the chat warm stamps its first phase: another
2013 # role loading first (embed on a cold start) leaves the tracker silent
2014 # for many seconds, and a bare scanner reads as a hang.
2015 with contextlib.suppress(Exception):
2016 call_from_thread(self, widget.set_thinking_status, msg.ENGINE_WARMING)
2017 if wait_chat_ready(on_progress=_paint, should_abort=lambda: self._turn_stopped(turn)):
2018 with contextlib.suppress(Exception):
2019 call_from_thread(self, widget.set_thinking_status, "")
2020 return True
2021 if self._turn_stopped(turn):
2022 return False
2023 error = chat_warm_error()
2024 text = (
2025 f"{msg.ENGINE_LOAD_FAILED.format(error=error)}\n{msg.ENGINE_FAILED_HINT}"
2026 if error is not None
2027 else msg.ENGINE_NOT_READY
2028 )
2029 with contextlib.suppress(Exception):
2030 call_from_thread(self, widget.append_content, text)
2031 return False
2033 def _show_warm_tip_once(self) -> None:
2034 """Toast the keep-warm tip on the session's first cold-engine wait. Worker thread."""
2035 if cfg.keep_engine_warm or self._warm_tip_shown:
2036 return
2037 self._warm_tip_shown = True
2038 with contextlib.suppress(Exception):
2039 call_from_thread(self, self.notify, msg.ENGINE_WARM_TIP, timeout=8)
2041 def _maybe_extract_memories(self, question: str, answer: str) -> None:
2042 """Spawn auto-extraction for the finished turn, when enabled and idle.
2044 Runs on the main thread (scheduled from the stream worker). Skips while
2045 indexing so the extraction's embed call never contends with a sync.
2046 """
2047 from lilbee.app.memory import auto_extract_enabled
2049 if not answer or not auto_extract_enabled() or self._indexing_active():
2050 return
2051 self._extract_memories_worker(question, answer)
2053 def _indexing_active(self) -> bool:
2054 """True while a sync/add/import/wiki task is running (embed worker is busy).
2056 Wiki counts: a build embeds citations and a draft accept re-chunks and
2057 re-indexes the page it publishes.
2058 """
2059 from lilbee.cli.tui.task_queue import TaskType
2061 busy = {
2062 TaskType.SYNC.value,
2063 TaskType.ADD.value,
2064 TaskType.IMPORT.value,
2065 TaskType.WIKI.value,
2066 }
2067 return any(task.task_type in busy for task in self._task_bar.queue.active_tasks)
2069 @work(thread=True, name="chat_memory_extract", exit_on_error=False)
2070 def _extract_memories_worker(self, question: str, answer: str) -> None:
2071 """Extract durable memories off the UI thread; notify how many landed."""
2072 from lilbee.app.memory import auto_extract
2074 stored = auto_extract(question, answer)
2075 if stored:
2076 call_from_thread(self, self.notify, msg.MEMORY_AUTO_EXTRACTED.format(count=len(stored)))
2078 def _on_embedding_mismatch(
2079 self, exc: EmbeddingModelMismatchError, question: str, widget: AssistantMessage
2080 ) -> None:
2081 """Offer to adopt the index's embedder (same dim) or explain the rebuild path."""
2082 if not exc.dims_match:
2083 widget.append_content(msg.EMBED_ADOPT_REBUILD_NOTICE.format(dim=exc.persisted_dim))
2084 return
2085 widget.append_content(msg.EMBED_ADOPT_NOTICE.format(model=exc.persisted_model))
2086 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
2088 self.app.push_screen(
2089 ConfirmDialog(
2090 msg.EMBED_ADOPT_CONFIRM_TITLE,
2091 msg.EMBED_ADOPT_CONFIRM_MESSAGE.format(model=exc.persisted_model),
2092 ),
2093 lambda ok: self._on_adopt_confirm(ok, exc.persisted_model, question),
2094 )
2096 def _on_adopt_confirm(self, confirmed: bool | None, ref: str, question: str) -> None:
2097 """Run the adopt+retry in a worker thread, or report the cancellation."""
2098 if not confirmed:
2099 self.notify(msg.EMBED_ADOPT_CANCELLED)
2100 return
2101 self.notify(msg.EMBED_ADOPTING.format(model=ref))
2102 self._adopt_and_retry(ref, question)
2104 @work(thread=True)
2105 def _adopt_and_retry(self, ref: str, question: str) -> None:
2106 """Schedule the adopt+retry on a worker thread (pull may be slow)."""
2107 self._do_adopt_and_retry(ref, question)
2109 def _do_adopt_and_retry(self, ref: str, question: str) -> None:
2110 """Switch to embedder *ref* (downloading if needed), then re-ask. Worker thread."""
2111 from lilbee.app.models import adopt_embedder
2113 try:
2114 result = adopt_embedder(ref)
2115 except Exception as exc: # surfaced to the user, never silently swallowed
2116 log.debug("Embedder adopt failed", exc_info=True)
2117 call_from_thread(
2118 self, self.notify, msg.EMBED_ADOPT_FAILED.format(error=exc), severity="error"
2119 )
2120 return
2121 call_from_thread(self, self.notify, msg.EMBED_ADOPTED.format(model=ref))
2122 if result.reindex_required: # the re-asked question would meet the same refusal
2123 call_from_thread(self, self._offer_adopted_rebuild)
2124 return
2125 call_from_thread(self, self._send_message, question)
2127 def _offer_adopted_rebuild(self) -> None:
2128 """Offer the rebuild an adopted embedder still needs."""
2129 self.app.offer_rebuild(msg.SETTINGS_REINDEX_MESSAGE)
2131 def _consume_stream(self, stream: Any, turn: _Turn, response_parts: list[str]) -> None:
2132 """Pull tokens off *stream* into *turn*'s bubble, batching UI updates to ~50 ms windows."""
2133 widget = turn.widget
2134 reason_buf: list[str] = []
2135 content_buf: list[str] = []
2136 timings = _StreamTimings(last_flush=time.monotonic())
2138 def flush() -> None:
2139 if reason_buf:
2140 call_from_thread(self, widget.append_reasoning, "".join(reason_buf))
2141 reason_buf.clear()
2142 if content_buf:
2143 call_from_thread(self, widget.append_content, "".join(content_buf))
2144 content_buf.clear()
2146 for token in stream:
2147 if self._turn_stopped(turn):
2148 break
2149 try:
2150 if isinstance(token, RetrievalNotice):
2151 status = msg.SEARCHING_FOR.format(query=token.query)
2152 call_from_thread(self, widget.set_thinking_status, status)
2153 continue
2154 if isinstance(token, AnswerSources):
2155 # Shown under the answer, saved beside it: never part of its text.
2156 turn.source_refs = token.refs()
2157 call_from_thread(self, widget.show_sources, turn.source_refs)
2158 continue
2159 self._buffer_token(token, reason_buf, content_buf, response_parts)
2160 self._maybe_flush(flush, timings)
2161 except Exception:
2162 break # App shutting down (Ctrl-C) -- stop streaming
2163 with contextlib.suppress(Exception):
2164 flush()
2166 @staticmethod
2167 def _buffer_token(
2168 token: Any,
2169 reason_buf: list[str],
2170 content_buf: list[str],
2171 response_parts: list[str],
2172 ) -> None:
2173 """Append *token* to the right buffer; record response content for history."""
2174 if token.is_reasoning:
2175 reason_buf.append(token.content)
2176 elif token.content:
2177 response_parts.append(token.content)
2178 content_buf.append(token.content)
2180 def _maybe_flush(self, flush: Callable[[], None], timings: _StreamTimings) -> None:
2181 """Run *flush* on its interval. The chat log is anchored, so Textual
2182 keeps the answer's tail in view as it grows without a scroll of ours.
2183 """
2184 now = time.monotonic()
2185 if now - timings.last_flush >= _STREAM_FLUSH_INTERVAL:
2186 flush()
2187 timings.last_flush = now
2189 def _save_turn(self, turn: _Turn) -> None:
2190 """Add *turn*'s answer to the history and its session. Worker thread.
2192 The answer joins the history only while the turn's conversation is still
2193 on screen; it is saved to the turn's session either way.
2194 """
2195 if not turn.answer:
2196 return
2197 with self._history_lock:
2198 # A resume or /clear mid-answer replaced the conversation; the
2199 # answer belongs to the one that is gone.
2200 if turn.generation == self._conversation_generation:
2201 self._history.append({"role": "assistant", "content": turn.answer})
2202 # No trim here: the next turn compacts before it builds its prompt, so
2203 # trimming now would drop turns without folding them in.
2204 self._save_assistant_turn(turn.session_id, turn.answer, turn.source_refs)
2206 @on(_TurnEnded)
2207 def _on_turn_ended(self, event: _TurnEnded) -> None:
2208 """Drop the busy flag first, then finish the turn's bubble if it is on screen."""
2209 turn = event.turn
2210 self._turn = None
2211 self.stopping = False
2212 self.streaming = False
2213 self._maybe_extract_memories(turn.question, turn.answer)
2214 if turn.generation == self._conversation_generation:
2215 self._show_turn_end(turn)
2217 def _show_turn_end(self, turn: _Turn) -> None:
2218 """Finish *turn*'s bubble, its cancel note, the context chip and the search toast."""
2219 if turn.stop.is_set():
2220 turn.widget.append_content(msg.STREAM_CANCELLED)
2221 turn.widget.finish()
2222 if not turn.answer:
2223 return
2224 self._refresh_context_usage()
2225 if (
2226 cfg.chat_mode == ChatMode.SEARCH.value
2227 and self._embedding_ready()
2228 and not turn.source_refs
2229 ):
2230 self._notify_no_results()
2232 def _notify_no_results(self) -> None:
2233 self.notify(msg.CHAT_MODE_SEARCH_NO_RESULTS, severity="warning")
2235 @staticmethod
2236 def _history_budget() -> int:
2237 """Token budget for everything this conversation carries into the prompt."""
2238 return history_budget(cfg.chat_n_ctx_target)
2240 def _compact_history(self, session_id: str | None, generation: int) -> None:
2241 """Fold turns that no longer fit into the rolling summary. Worker thread only.
2243 Runs before a prompt is built rather than after a turn lands, so the
2244 summary is always current with what is about to be sent, and a resumed
2245 conversation compacts what it cannot carry instead of dropping it.
2247 The summarizing model call is slow, so it happens without the lock held;
2248 only the known prefix is removed afterwards, which stays correct if the
2249 user sends another turn meanwhile. The result applies only while
2250 *generation* is still the conversation on screen, and the summary is
2251 saved to *session_id*, the session the turn started in.
2252 """
2253 with self._history_lock:
2254 history = list(self._history)
2255 summary = self._summary
2256 budget = self._history_budget()
2257 if not cfg.chat_compaction:
2258 # Default path, deliberately free: prune exactly to the limit, no
2259 # model call. The summary is charged against the same budget so a
2260 # session compacted on earlier hardware still carries its notes.
2261 reserved = sum(estimate_tokens(m) for m in summary_messages(summary))
2262 dropped = overflow(history, max_tokens=max(1, budget - reserved))
2263 if not dropped:
2264 return
2265 with self._history_lock:
2266 if generation != self._conversation_generation:
2267 return
2268 del self._history[: len(dropped)]
2269 call_from_thread(self, self._on_history_trimmed, len(dropped))
2270 return
2271 # Compaction on: fire early, clear deep (see COMPACT_TRIGGER_FRACTION).
2272 if not compaction_due(history, summary, max_tokens=budget):
2273 return
2274 dropped = foldable(history)
2275 if not dropped:
2276 # Nothing but the tail, and it alone fills the budget. Folding it
2277 # would summarize the very turn being answered; prompt_history windows
2278 # it instead.
2279 return
2280 # Condensing blocks this turn on a model call: seconds on a GPU, tens of
2281 # seconds on a CPU-only host. An unannounced pause that long is
2282 # indistinguishable from a hang, so say what is happening first.
2283 call_from_thread(self, self._set_compacting, True)
2284 try:
2285 result = get_services().searcher.summarize_history(dropped, summary)
2286 finally:
2287 call_from_thread(self, self._set_compacting, False)
2288 with self._history_lock:
2289 if generation != self._conversation_generation:
2290 # A resume or /clear replaced the conversation during the model
2291 # call; the fold belongs to the one that is gone.
2292 return
2293 del self._history[: len(dropped)]
2294 self._summary = result.summary
2295 if session_id and result.summary and cfg.sessions_enabled:
2296 # A summary for a session deleted mid-chat is not worth a crash; the
2297 # next user turn reopens one and re-summarizes from there. The
2298 # toggle is re-checked because the fold keeps working in memory
2299 # after sessions go off, but must not reach the disk.
2300 with contextlib.suppress(SessionNotFoundError):
2301 get_services().session_store.set_summary(session_id, result.summary)
2302 call_from_thread(self, self._on_history_compacted, result.condensed, result.stranded)
2304 def _set_compacting(self, compacting: bool) -> None:
2305 """Flip the chip into (or out of) its condensing state. Main thread only."""
2306 with contextlib.suppress(NoMatches):
2307 self.query_one("#context-chip", ContextChip).compacting = compacting
2309 def _refresh_context_usage(self) -> None:
2310 """Push current history pressure to the chip. Main thread only.
2312 Cheap: the same char/4 estimate the windower already uses, over messages
2313 that are in memory anyway. Recomputed per turn rather than per keystroke.
2314 """
2315 with self._history_lock:
2316 history = list(self._history)
2317 summary = self._summary
2318 budget = self._history_budget()
2319 used = sum(estimate_tokens(m) for m in history)
2320 used += sum(estimate_tokens(m) for m in summary_messages(summary))
2321 with contextlib.suppress(NoMatches):
2322 self.query_one("#context-chip", ContextChip).usage = used / max(1, budget)
2324 def _mark_context_boundary(self, *titles: str) -> None:
2325 """Draw rules in the log where the model's view of the chat changed.
2327 A rich Rule, not a hand-drawn "-- text --": it draws the line out to the
2328 full width itself, which is what makes it read as a boundary rather than
2329 as another message. Guarded because the worker can land this after the
2330 user has navigated off the chat screen.
2331 """
2332 # mount() is async: the anchor may not be in the log yet, and mounting
2333 # before a non-child raises. Appending reads fine in that race.
2334 anchor = self._active_question
2335 if anchor is not None and not anchor.is_mounted:
2336 anchor = None
2337 with contextlib.suppress(NoMatches):
2338 for title in titles:
2339 rule = Static(
2340 Rule(title=title, characters="─", style="dim"),
2341 classes="compaction-marker",
2342 )
2343 if anchor is None:
2344 self._chat_log.mount(rule)
2345 else:
2346 self._chat_log.mount(rule, before=anchor)
2348 def _on_history_trimmed(self, dropped: int) -> None:
2349 """Mark where turns left the model's view with nothing standing in for them.
2351 The compaction-off path. Same rule as compaction so the log reads
2352 consistently, different words because nothing was summarized.
2353 """
2354 self._refresh_context_usage()
2355 self._mark_context_boundary(msg.CHAT_TRIMMED.format(count=dropped))
2356 with contextlib.suppress(NoMatches):
2357 self.notify(msg.CHAT_TRIMMED_TOAST, severity="warning")
2359 def _on_history_compacted(self, condensed: int, stranded: int) -> None:
2360 """Mark where the model's memory of this conversation turns into a summary.
2362 Styling lives in chat.tcss under .compaction-marker. Guarded because the
2363 worker can land this after the user navigated off the chat screen.
2365 Stranded turns get their own line: they are gone from the model's view
2366 with nothing standing in for them, and a user whose model has forgotten
2367 something is owed the reason rather than left to infer it.
2368 """
2369 self._refresh_context_usage()
2370 titles = [msg.CHAT_COMPACTED.format(count=condensed)]
2371 if stranded:
2372 titles.append(msg.CHAT_COMPACTION_STRANDED.format(count=stranded))
2373 self._mark_context_boundary(*titles)
2374 with contextlib.suppress(NoMatches):
2375 self.notify(
2376 msg.CHAT_COMPACTED_STRANDED_TOAST if stranded else msg.CHAT_COMPACTED_TOAST,
2377 severity="warning",
2378 )
2380 def action_scroll_up(self) -> None:
2381 self._chat_log.scroll_page_up()
2383 def action_scroll_down(self) -> None:
2384 self._chat_log.scroll_page_down()
2386 def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
2387 """Keep the footer honest about mode-dependent bindings.
2389 - ``cancel_stream`` (Ctrl+C) only does something while streaming in
2390 INSERT mode; otherwise the App's Quit binding takes the slot.
2391 """
2392 if action == "cancel_stream":
2393 return self.streaming and not self.stopping and self._insert_mode
2394 if action in _MODE_ACTIONS:
2395 # A focused drawer keeps the mode keys for its own bindings.
2396 if self._focus_in_drawer():
2397 return False
2398 return action == "enter_normal_mode" or self._leaves_normal_mode(action)
2399 if action == "enter_model_strip":
2400 # NORMAL mode parks the cursor on the transcript, and that is the
2401 # only place these letters are free. Stated as where they DO apply,
2402 # so a drawer, a dialog or any later focus target keeps its own
2403 # letters without having to be named here.
2404 focused = self.focused
2405 return focused is not None and focused.id == "chat-log"
2406 return super().check_action(action, parameters)
2408 def action_enter_normal_mode(self) -> None:
2409 """Esc dismisses the overlay if visible; otherwise drops into NORMAL mode."""
2410 overlay = self._completion_overlay
2411 if overlay.is_visible:
2412 # Revert any previewed candidate back to what the user typed.
2413 if self._completion_origin is not None and self._chat_input.value != (
2414 self._completion_origin
2415 ):
2416 self._set_input(self._completion_origin)
2417 self._completion_origin = None
2418 overlay.hide()
2419 # Backing out of the command list leaves nothing worth keeping in
2420 # a lone slash, and it would hijack the next message as /word.
2421 if self._chat_input.value.strip() == "/":
2422 self._set_input("")
2423 return
2424 if isinstance(self.focused, Select) or self._focus_in_model_bar():
2425 # Leaving the model strip should put us back in INSERT so the
2426 # user can type their next prompt; routing through the helper
2427 # makes sure can_focus is re-enabled.
2428 self._enter_insert_mode()
2429 return
2430 self._insert_mode = False
2431 # Make the chat input unfocusable in NORMAL mode so Tab traversal
2432 # skips past it AND a programmatic focus restore (modal close,
2433 # screen pop) cannot land on it. The user re-enters INSERT
2434 # explicitly via i/a/o/Enter or by clicking the input.
2435 self._chat_input.can_focus = False
2436 self.set_focus(self._chat_log)
2437 self._update_input_style()
2439 def action_cancel_stream(self) -> None:
2440 """Stop the live answer. Bound to Ctrl+C from INSERT mode."""
2441 self._stop_turn()
2443 def _stop_turn(self) -> None:
2444 """Ask the live turn to stop and sever its inference call.
2446 The body sees the stop at the engine wait, before and after the fold,
2447 between tokens and in its error branch; ``cancel_inference`` severs the
2448 stream's transport to unblock a reader stuck in a socket read. The turn
2449 stays busy until its worker ends it.
2450 """
2451 turn = self._turn
2452 if turn is None:
2453 return
2454 turn.stop.set()
2455 get_services().cancel_inference()
2456 self.stopping = True
2458 def apply_model_change(self) -> None:
2459 """Swap to the new chat model without freezing the UI or losing an answer.
2461 Reloading the fleet for the new model is a multi-second restart, so it
2462 runs in a thread worker instead of on the event loop. The worker reloads
2463 only the chat role; the provider retires any still-busy client across the
2464 restart and serializes overlapping reloads, so the worker can start at
2465 once without waiting for other workers.
2467 A switch requested mid-answer is queued, not applied: restarting the chat
2468 server under a live stream kills the answer being read. The queued switch
2469 runs on leaving the streaming state, so it covers a finished, cancelled
2470 and cleared answer alike.
2471 """
2472 if self.swapping_model:
2473 # A swap is already loading; a second one (rapid /model, or the model
2474 # bar re-clicked while the input is disabled) would spawn a duplicate
2475 # worker and a duplicate completion toast. The in-flight reload already
2476 # coalesces onto the latest cfg, so ignore the re-entry.
2477 self.notify(msg.CHAT_MODEL_SWITCHING, severity="warning", timeout=3)
2478 return
2479 if self.streaming:
2480 from lilbee.catalog.formatting import display_label_for_ref
2482 # cfg already holds the new ref, so a second queued switch needs no
2483 # extra state.
2484 self._model_switch_queued = True
2485 self.app.notify(
2486 msg.MODEL_SWAP_QUEUED.format(name=display_label_for_ref(cfg.chat_model))
2487 )
2488 return
2489 self.swapping_model = True
2490 self.app.notify(msg.MODEL_SWAP_APPLYING)
2491 self._reload_chat_model_worker()
2493 def _apply_input_busy_state(self) -> None:
2494 """Disable the chat input while a swap or placement reload is loading, and
2495 say why in the placeholder so a person is never left facing a dead input
2496 with no explanation.
2498 Restores focus and the default placeholder when the fleet is idle again so
2499 the user can type without re-clicking the input that was disabled out from
2500 under them. Guarded because the unblock can fire (via ``call_from_thread``
2501 or a bubbled message) after the user navigated away and the input is no
2502 longer mounted.
2503 """
2504 no_model = not self.app.chat_is_ready
2505 busy = self.swapping_model or self.reloading_placement or no_model
2506 with contextlib.suppress(NoMatches):
2507 inp = self._chat_input
2508 inp.disabled = busy
2509 if no_model:
2510 inp.placeholder = msg.CHAT_INPUT_NO_MODEL
2511 elif self.swapping_model:
2512 from lilbee.catalog.formatting import display_label_for_ref
2514 inp.placeholder = msg.CHAT_INPUT_SWITCHING.format(
2515 name=display_label_for_ref(cfg.chat_model)
2516 )
2517 elif self.reloading_placement:
2518 inp.placeholder = msg.CHAT_INPUT_RELOADING
2519 else:
2520 inp.placeholder = msg.CHAT_INPUT_PLACEHOLDER_DEFAULT
2521 if not busy and self._insert_mode:
2522 self.set_focus(inp)
2524 def watch_swapping_model(self, swapping: bool) -> None:
2525 self._apply_input_busy_state()
2527 def watch_reloading_placement(self, reloading: bool) -> None:
2528 self._apply_input_busy_state()
2530 def on_fleet_body_placement_reloading(self, event: FleetBody.PlacementReloading) -> None:
2531 """Hold chat submissions while the Fleet drawer reloads the fleet."""
2532 self.reloading_placement = event.active
2534 @work(thread=True, name=_MODEL_SWAP_WORKER, exit_on_error=False)
2535 def _reload_chat_model_worker(self) -> None:
2536 """Reload the chat role and warm the new model before unblocking the input.
2538 ``reload_role(wait=True)`` re-plans and restarts the fleet for the new chat
2539 model (retrieval is untouched) and returns once the proxy is back up. The
2540 model is then warmed here rather than deferred to the user's next prompt:
2541 ``request_engine_warm`` drives the load and populates the provider warm
2542 tracker, which the task-bar footer renders (spinner, model, phase), and
2543 ``wait_chat_ready`` holds the input disabled until the model actually
2544 serves -- so the switch never hands back a live input in front of a model
2545 that has not loaded. The provider serializes overlapping reloads, so a
2546 rapid second swap coalesces onto the latest cfg.
2547 """
2548 from lilbee.app.placement import (
2549 chat_warm_error,
2550 request_engine_warm,
2551 wait_chat_ready,
2552 )
2554 worker = _get_worker()
2555 try:
2556 get_services().reload_role(WorkerRole.CHAT, wait=True)
2557 request_engine_warm()
2558 ready = wait_chat_ready(should_abort=lambda: worker.is_cancelled)
2559 except Exception as exc: # any reload failure becomes a toast, never a crash
2560 call_from_thread(self, self._on_model_swap_failed, str(exc))
2561 return
2562 if worker.is_cancelled:
2563 return
2564 error = None if ready else chat_warm_error()
2565 if error:
2566 call_from_thread(self, self._on_model_swap_failed, error)
2567 else:
2568 call_from_thread(self, self._on_model_swapped)
2570 def _on_model_swapped(self) -> None:
2571 """Main-thread completion: unblock the input and confirm the new model."""
2572 from lilbee.catalog.formatting import display_label_for_ref
2574 self.swapping_model = False
2575 self.app.notify(msg.MODEL_SWAP_DONE.format(name=display_label_for_ref(cfg.chat_model)))
2577 def _on_model_swap_failed(self, error: str) -> None:
2578 """Main-thread failure: unblock the input and surface the error."""
2579 self.swapping_model = False
2580 self.app.notify(msg.MODEL_SWAP_FAILED.format(error=error), severity="error")
2582 @on(Markdown.LinkClicked)
2583 def _open_answer_link(self, event: Markdown.LinkClicked) -> None:
2584 """Open a link clicked in an answer: ``file:`` citations open in the OS
2585 default app for the file type; web links open in the browser."""
2586 event.stop()
2587 if event.href.startswith("file://"):
2588 open_local_file(event.href)
2589 else:
2590 self.app.open_url(event.href)
2592 async def action_toggle_markdown(self) -> None:
2593 """Toggle between Markdown and plain-text rendering for chat responses."""
2594 cfg.markdown_rendering = not cfg.markdown_rendering
2595 use_md = cfg.markdown_rendering
2596 chat_log = self._chat_log
2597 for widget in chat_log.query(AssistantMessage):
2598 await widget.rebuild_content_widget(use_md)
2599 label = "Markdown" if use_md else "Plain text"
2600 self.notify(msg.CHAT_RENDERING.format(label=label))
2602 def _sync_work_live(self) -> bool:
2603 """True while a sync, add or import is queued, running or unwinding after a cancel."""
2604 return self._task_bar.has_live_work(self._sync_work_types())
2606 @staticmethod
2607 def _sync_work_types() -> frozenset[str]:
2608 from lilbee.cli.tui.task_queue import TaskType
2610 return frozenset({TaskType.SYNC.value, TaskType.ADD.value, TaskType.IMPORT.value})
2612 def _notify_sync_refused(self) -> None:
2613 """Say why a sync is refused: other work runs, or a cancelled task is still stopping."""
2614 queue = self._task_bar.queue
2615 rows = queue.queued_tasks + queue.active_tasks
2616 running = any(t.task_type in self._sync_work_types() for t in rows)
2617 stopping = [] if running else self._task_bar.cancelling_tasks(self._sync_work_types())
2618 text = (
2619 msg.SYNC_CANCELLING.format(kind=stopping[0].task_type)
2620 if stopping
2621 else msg.SYNC_ALREADY_ACTIVE
2622 )
2623 self.notify(text, severity="warning")
2625 def run_sync(
2626 self,
2627 *,
2628 force_rebuild: bool = False,
2629 prune_ignored: bool = False,
2630 ) -> None:
2631 """Enqueue a document sync (or full rebuild) in the task bar.
2633 While other work runs, a sync is refused and a rebuild waits for the work to end.
2634 """
2635 if self._sync_work_live():
2636 if force_rebuild:
2637 self._rebuild_owed = True
2638 self.notify(msg.REBUILD_OWED)
2639 else:
2640 self._notify_sync_refused()
2641 return
2642 from lilbee.cli.tui.task_queue import TaskType
2644 if force_rebuild:
2645 self._rebuild_owed = False
2646 # Clear the pending hint so the bar shows live sync progress
2647 # instead of the stale "N docs to sync" line.
2648 self._task_bar.clear_pending_sync()
2650 def _target(reporter: ProgressReporter) -> None:
2651 try:
2652 self._do_sync(reporter, force_rebuild=force_rebuild, prune_ignored=prune_ignored)
2653 finally:
2654 # Re-detect after every sync attempt: success drives the
2655 # count to 0, failure or cancel leaves the still-pending
2656 # files counted so the hint reappears.
2657 self._task_bar.start_detect_pending()
2659 label = msg.TASK_NAME_REBUILD if force_rebuild else msg.TASK_NAME_SYNC
2660 self._task_bar.start_task(label, TaskType.SYNC, _target, indeterminate=True)
2662 def _start_owed_rebuild(self) -> None:
2663 """Start the owed rebuild once the last sync, add or import has ended in any way."""
2664 if not self._rebuild_owed or self._sync_work_live():
2665 return
2666 self._rebuild_owed = False
2667 self.run_sync(force_rebuild=True)
2669 def _do_sync(
2670 self,
2671 reporter: ProgressReporter,
2672 *,
2673 force_rebuild: bool = False,
2674 prune_ignored: bool = False,
2675 ) -> None:
2676 """Sync body. Runs on worker thread."""
2677 from lilbee.data.ingest import sync
2679 reporter.update(0, msg.SYNC_STATUS_SYNCING, indeterminate=True)
2680 on_progress = build_sync_progress_callback(reporter)
2681 try:
2682 result = asyncio_loop.run(
2683 sync(
2684 quiet=True,
2685 on_progress=on_progress,
2686 cancel=reporter,
2687 force_rebuild=force_rebuild,
2688 prune_ignored=prune_ignored,
2689 started_by="the lilbee TUI",
2690 )
2691 )
2692 except asyncio.CancelledError as exc:
2693 raise TaskCancelledError(msg.SYNC_CANCELLED_RESUME) from exc
2694 if prune_ignored:
2695 call_from_thread(self, self.notify, msg.prune_ignored_message(len(result.removed)))
2696 self._notify_unreadable_folders(result)
2697 if result.failed:
2698 # The footer notice lasts under two seconds; the toast stays long enough to read.
2699 call_from_thread(
2700 self,
2701 self.notify,
2702 msg.SYNC_FAILED_TOAST.format(count=len(result.failed)),
2703 severity="error",
2704 timeout=_SYNC_FAILED_TOAST_SECONDS,
2705 )
2706 raise RuntimeError(msg.SYNC_FAILED_FILES.format(files=", ".join(result.failed)))
2707 if result.skipped:
2708 call_from_thread(
2709 self,
2710 self.notify,
2711 msg.sync_skipped_message(result, tui_log_path()),
2712 severity="warning",
2713 )
2714 if result.held_out:
2715 call_from_thread(
2716 self,
2717 self.notify,
2718 msg.SYNC_HELD_OUT.format(count=len(result.held_out)),
2719 severity="warning",
2720 )
2722 def action_focus_commands(self) -> None:
2723 """Focus chat input and pre-fill with '/' for command entry."""
2724 # Route through the helper so can_focus is re-enabled when this
2725 # action fires from NORMAL mode; bare ``inp.focus()`` would
2726 # silently no-op while the input is intentionally unfocusable.
2727 self._enter_insert_mode()
2728 inp = self._chat_input
2729 if not inp.value.startswith("/"):
2730 inp.value = "/"
2731 inp.action_end()
2733 def action_toggle_chat_mode(self) -> None:
2734 """F3: flip between Search and Chat mode."""
2735 try:
2736 toggle = self.query_one(ChatModeToggle)
2737 except NoMatches:
2738 return
2739 if not toggle.toggle():
2740 return
2741 label = (
2742 msg.CHAT_MODE_SEARCH_LABEL
2743 if cfg.chat_mode == ChatMode.SEARCH.value
2744 else msg.CHAT_MODE_CHAT_LABEL
2745 )
2746 self.notify(msg.CHAT_MODE_SET.format(label=label))
2748 def action_cycle_scope(self) -> None:
2749 """``s``: cycle the scope chip when it is currently visible."""
2750 from lilbee.cli.tui.widgets.scope_chip import ScopeChip
2752 try:
2753 chip = self.query_one("#scope-chip", ScopeChip)
2754 except NoMatches:
2755 return
2756 if chip.has_class("-hidden"):
2757 return
2758 chip.cycle_scope()
2760 def action_complete(self) -> None:
2761 """Tab: fill the shared prefix, then cycle matches (readline / vim style).
2763 - Insert mode + chat input focused + dropdown closed but matches
2764 exist: open it, fill the longest common prefix, else preview the
2765 first match.
2766 - Insert mode + chat input focused + dropdown open: fill any further
2767 shared prefix, otherwise preview the next match.
2768 - Insert mode + chat input focused + no matches: insert ``\\t`` so
2769 users can type tab characters directly.
2770 - Normal mode or focus elsewhere: advance through the focus
2771 chain so Tab still walks every focusable widget.
2772 """
2773 inp = self._chat_input
2774 if not self._insert_mode or not inp.has_focus:
2775 self._tab_into_fleet_or_next()
2776 return
2777 if self._completion_overlay.is_visible:
2778 self._complete_from_overlay()
2779 return
2780 self._open_completions(self._complete_after_open)
2782 def _complete_after_open(self, opened: bool) -> None:
2783 """Tab, once the dropdown listing is in: complete from it, else type a tab."""
2784 if opened:
2785 self._complete_from_overlay()
2786 else:
2787 self._chat_input.insert("\t")
2789 def _complete_from_overlay(self) -> None:
2790 """Fill the shared prefix of the open dropdown, else preview the next match."""
2791 if self._fill_common_prefix():
2792 return
2793 self._preview_next()
2795 def _focus_in_drawer(self) -> bool:
2796 """True when keyboard focus is inside an open drawer, so Esc / Enter / i / a / o
2797 reach that drawer's own controls instead of switching the chat mode.
2799 Asked of the Drawer base rather than one drawer class: a drawer that had
2800 to name itself here would otherwise swallow its own Enter until someone
2801 noticed.
2802 """
2803 return drawer_holding(self.focused) is not None
2805 def _focus_in_model_bar(self) -> bool:
2806 """True when focus is on any model-strip member.
2808 Asked of the container rather than of each member class so a member
2809 added later is covered without a second edit here.
2810 """
2811 focused = self.focused
2812 return bool(focused and any(isinstance(n, ModelBar) for n in focused.ancestors_with_self))
2814 def _tab_into_fleet_or_next(self) -> None:
2815 """Jump Tab into the open Fleet drawer's first toggle so the placement
2816 editor is reachable without tabbing past every widget; once focus is
2817 inside the drawer, Tab cycles within it as usual."""
2818 drawer = first_direct_child(self.screen, FleetDrawer)
2819 if drawer is None:
2820 self.screen.focus_next()
2821 return
2822 focused = self.screen.focused
2823 inside = focused is not None and drawer in focused.ancestors_with_self
2824 toggles = drawer.query(".dev-toggle")
2825 if not inside and toggles:
2826 self.set_focus(toggles.first())
2827 return
2828 self.screen.focus_next()
2830 def action_complete_next(self) -> None:
2831 """Ctrl+N: preview the next match, opening the dropdown if it is closed (vim ``<C-n>``)."""
2832 if not self._chat_input.has_focus:
2833 # Not a completion here; skip so an open overlay (e.g. the sessions
2834 # drawer) can bind Ctrl+N instead of this priority binding eating it.
2835 raise SkipAction()
2836 if self._completion_overlay.is_visible:
2837 self._preview_next()
2838 else:
2839 self._open_completions(self._preview_next_if_opened)
2841 def action_complete_prev(self) -> None:
2842 """Ctrl+P: preview the previous match, opening the dropdown if it is closed."""
2843 if not self._chat_input.has_focus:
2844 return
2845 if self._completion_overlay.is_visible:
2846 self._preview_prev()
2847 else:
2848 self._open_completions(self._preview_prev_if_opened)
2850 def _preview_next_if_opened(self, opened: bool) -> None:
2851 if opened:
2852 self._preview_next()
2854 def _preview_prev_if_opened(self, opened: bool) -> None:
2855 if opened:
2856 self._preview_prev()
2858 def _preview_next(self) -> None:
2859 """Preview the highlighted match if none is previewed yet, else step forward."""
2860 overlay = self._completion_overlay
2861 if self._chat_input.value == self._completion_origin:
2862 display = overlay.get_current()
2863 else:
2864 display = overlay.cycle_next()
2865 if display is not None:
2866 self._preview_completion(display)
2868 def _preview_prev(self) -> None:
2869 """Step the highlight backward (wrapping to the last match) and preview it."""
2870 display = self._completion_overlay.cycle_prev()
2871 if display is not None:
2872 self._preview_completion(display)
2874 def _open_completions(self, then: Callable[[bool], None]) -> None:
2875 """Show the dropdown for the current input, then call *then* with whether it opened.
2877 A path or model argument is listed on a worker, so *then* runs after the
2878 listing and only if the input text did not change meanwhile.
2879 """
2880 text = self._chat_input.value
2881 if reads_a_folder(text):
2882 self._list_path_completions(text, then)
2883 return
2884 if reads_models(text):
2885 self._complete_models(text, then)
2886 return
2887 self._show_opened(text, get_completions(text), then)
2889 def _show_opened(self, text: str, options: list[str], then: Callable[[bool], None]) -> None:
2890 """Show *options* as the dropdown for *text* and report whether any rows opened."""
2891 if text != self._chat_input.value:
2892 return
2893 if options:
2894 self._completion_origin = text
2895 self._completion_overlay.show_completions(options)
2896 then(bool(options))
2898 def _completion_value(self, display: str) -> str:
2899 """Full input text produced by accepting ``display``, keeping the typed prefix.
2901 Path completions are basenames, so the directory the user already
2902 typed (``~/``, ``./``, absolute) is preserved and only the final
2903 segment is replaced.
2904 """
2905 text = (
2906 self._completion_origin
2907 if self._completion_origin is not None
2908 else (self._chat_input.value)
2909 )
2910 if " " not in text:
2911 return display
2912 cmd, _, partial = text.partition(" ")
2913 if cmd.lower() in PATH_ARG_COMMANDS:
2914 head = path_completion_prefix(partial)
2915 return f"{cmd} {head}{display}"
2916 return f"{cmd} {display}"
2918 def _set_input(self, value: str) -> None:
2919 """Replace the input value without triggering the live-refresh of the dropdown."""
2920 inp = self._chat_input
2921 if inp.value == value:
2922 return
2923 # The setter posts Changed asynchronously; flag one event to ignore so
2924 # the previewed candidate doesn't re-filter (and collapse) the dropdown.
2925 # (The value setter already moves the cursor to the end.)
2926 self._suppress_refresh += 1
2927 inp.value = value
2929 def _preview_completion(self, display: str) -> None:
2930 """Write the highlighted candidate into the input, leaving the dropdown open."""
2931 self._set_input(self._completion_value(display))
2933 def _fill_common_prefix(self) -> bool:
2934 """Extend the input to the longest prefix shared by all matches; True if it grew."""
2935 overlay = self._completion_overlay
2936 values = [self._completion_value(d) for d in overlay.options]
2937 shared = longest_common_prefix(values)
2938 if len(shared) <= len(self._chat_input.value):
2939 return False
2940 self._set_input(shared)
2941 # Re-filter for the newly completed prefix (descends into a directory,
2942 # narrows the model list, etc.).
2943 self._refresh_completion_overlay()
2944 return True
2946 def action_history_prev(self) -> None:
2947 """Up arrow: cycle the dropdown if visible, else recall previous history entry."""
2948 if not self._insert_mode:
2949 raise SkipAction()
2950 inp = self._chat_input
2951 if not inp.has_focus:
2952 raise SkipAction()
2953 # When the completion dropdown is up, Up navigates the dropdown
2954 # (vim/Emacs-style) rather than recalling history.
2955 overlay = self._completion_overlay
2956 if overlay.is_visible:
2957 self._preview_prev()
2958 return
2959 if not self._input_history:
2960 raise SkipAction()
2961 if self._history_index == -1:
2962 self._history_index = len(self._input_history) - 1
2963 elif self._history_index > 0:
2964 self._history_index -= 1
2965 else:
2966 return
2967 inp.value = self._input_history[self._history_index]
2968 inp.action_end()
2970 def action_history_next(self) -> None:
2971 """Down arrow: cycle the dropdown if visible, else recall next history entry."""
2972 if not self._insert_mode:
2973 raise SkipAction()
2974 inp = self._chat_input
2975 if not inp.has_focus:
2976 raise SkipAction()
2977 # When the completion dropdown is up, Down navigates the dropdown.
2978 overlay = self._completion_overlay
2979 if overlay.is_visible:
2980 self._preview_next()
2981 return
2982 if self._history_index == -1:
2983 raise SkipAction()
2984 if self._history_index < len(self._input_history) - 1:
2985 self._history_index += 1
2986 inp.value = self._input_history[self._history_index]
2987 inp.action_end()
2988 else:
2989 self._history_index = -1
2990 inp.value = ""
2992 @on(ChatInput.Changed, "#chat-input")
2993 def _on_chat_input_changed(self, event: ChatInput.Changed) -> None:
2994 """Refresh arg-hint and auto-show or hide the completion dropdown."""
2995 if not reads_models(self._chat_input.value):
2996 # Also on a programmatic edit: a submit clears the input that way, and
2997 # a /remove must not leave the removed model in the next listing.
2998 self._model_names = None
2999 # A listing still running belongs to the session that ended; the next
3000 # model argument starts its own, and the old result is dropped.
3001 self._model_session += 1
3002 self._listing_models = False
3003 self._model_request = ("", None)
3004 if self._suppress_refresh > 0:
3005 # A programmatic edit (preview / accept / revert) is managing the
3006 # overlay itself; consume one Changed and skip the live refresh.
3007 self._suppress_refresh -= 1
3008 self._refresh_arg_hint()
3009 return
3010 self._refresh_completion_overlay()
3011 self._refresh_arg_hint()
3013 def _refresh_completion_overlay(self) -> None:
3014 """Live-filter the dropdown against the current input, in command and arg modes alike."""
3015 text = self._chat_input.value
3016 if reads_a_folder(text):
3017 self._list_path_completions(text)
3018 return
3019 if reads_models(text):
3020 self._complete_models(text)
3021 return
3022 self._show_completions_for(text, get_completions(text))
3024 def _complete_models(self, text: str, then: Callable[[bool], None] | None = None) -> None:
3025 """Complete a model argument from the models listed once for this input.
3027 Until the listing ends, keep the latest request and start one listing; keys
3028 typed meanwhile neither wait for it nor start another.
3029 """
3030 if self._model_names is None:
3031 self._model_request = (text, then)
3032 if not self._listing_models:
3033 self._listing_models = True
3034 self._list_models(self._model_session)
3035 return
3036 options = model_completions(text, self._model_names)
3037 if then is None:
3038 self._show_completions_for(text, options)
3039 else:
3040 self._show_opened(text, options, then)
3042 @work(thread=True, group="model_completions", exit_on_error=False)
3043 def _list_models(self, session: int) -> None:
3044 """List the installed models off the event loop: the listing probes local servers."""
3045 names = list_model_options()
3046 if _get_worker().is_cancelled:
3047 return
3048 call_from_thread(self, self._models_listed, session, names)
3050 def _models_listed(self, session: int, names: list[str]) -> None:
3051 """Keep *names* and answer the latest request, unless the input left the model argument.
3053 A result from an earlier model argument session is dropped.
3054 """
3055 if session != self._model_session:
3056 return
3057 self._listing_models = False
3058 text, then = self._model_request
3059 self._model_request = ("", None)
3060 current = self._chat_input.value
3061 if not reads_models(current):
3062 return
3063 self._model_names = names
3064 self._complete_models(current, then if text == current else None)
3066 @work(thread=True, exclusive=True, group="path_completions", exit_on_error=False)
3067 def _list_path_completions(self, text: str, then: Callable[[bool], None] | None = None) -> None:
3068 """List the typed path's folder off the event loop; a newer request cancels this listing.
3070 With *then* (a Tab press) the rows open the dropdown and *then* runs; without it
3071 (a key press) the rows refresh the dropdown.
3072 """
3073 options = get_completions(text)
3074 if _get_worker().is_cancelled:
3075 return
3076 if then is None:
3077 call_from_thread(self, self._show_completions_for, text, options)
3078 else:
3079 call_from_thread(self, self._show_opened, text, options, then)
3081 def _show_completions_for(self, text: str, options: list[str]) -> None:
3082 """Apply *options* if *text* is still the input; a stale listing is dropped."""
3083 if text != self._chat_input.value:
3084 return
3085 overlay = self._completion_overlay
3086 if options:
3087 self._completion_origin = text
3088 overlay.show_completions(options)
3089 elif overlay.is_visible:
3090 overlay.hide()
3091 self._completion_origin = None
3093 def _refresh_arg_hint(self) -> None:
3094 """Push the current input value into the ArgHintLine."""
3095 self._arg_hint.update_for_input(self._chat_input.value)
3097 def refresh_model_bar(self) -> None:
3098 """Re-scan installed models and refresh the model bar.
3100 Show can arrive before the prompt area's descendants have mounted, and
3101 on a slow runner it does. A bar that is not in the DOM yet scans on its
3102 own mount, so a missing bar means the scan is already coming, not that
3103 it was skipped -- the re-scan here is what re-entering the screen needs.
3104 Querying regardless raised NoMatches out of the show handler, which
3105 Textual re-raises as an app crash.
3106 """
3107 for bar in self.query("#model-bar").results(ModelBar):
3108 bar.refresh_models()
3110 def action_vim_scroll_down(self) -> None:
3111 """Vim j: scroll down in normal mode."""
3112 if self._insert_mode:
3113 raise SkipAction()
3114 self._chat_log.scroll_down()
3116 def action_vim_scroll_up(self) -> None:
3117 """Vim k: scroll up in normal mode."""
3118 if self._insert_mode:
3119 raise SkipAction()
3120 self._chat_log.scroll_up()
3122 def action_vim_scroll_home(self) -> None:
3123 """Vim g: scroll to top in normal mode."""
3124 if self._insert_mode:
3125 raise SkipAction()
3126 self._chat_log.scroll_home()
3128 def action_vim_scroll_end(self) -> None:
3129 """Vim G: scroll to bottom in normal mode."""
3130 if self._insert_mode:
3131 raise SkipAction()
3132 self._chat_log.scroll_end()
3134 def action_half_page_down(self) -> None:
3135 """Ctrl-D: half-page down (vim style)."""
3136 log_widget = self._chat_log
3137 half = max(1, log_widget.size.height // 2)
3138 log_widget.scroll_relative(y=half)
3140 def action_half_page_up(self) -> None:
3141 """Ctrl-U: half-page up (vim style)."""
3142 log_widget = self._chat_log
3143 half = max(1, log_widget.size.height // 2)
3144 log_widget.scroll_relative(y=-half)