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