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