Coverage for src/lilbee/cli/tui/screens/chat_helpers.py: 100%
140 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
1"""Module-level helpers used by ChatScreen: progress callbacks, file cleanup, stream close."""
3from __future__ import annotations
5import contextlib
6import logging
7import subprocess
8import sys
9import time
10import webbrowser
11from collections.abc import Callable
12from dataclasses import dataclass
13from typing import TYPE_CHECKING, Any
14from urllib.parse import urlparse
15from urllib.request import url2pathname
17from lilbee.cli.tui import messages as msg
18from lilbee.cli.tui.widgets.task_bar_controller import ProgressReporter
19from lilbee.providers.base import ClosableIterator
20from lilbee.runtime.progress import (
21 BatchProgressEvent,
22 BatchStatus,
23 DetailedProgressCallback,
24 EmbedEvent,
25 EventType,
26 ExtractEvent,
27 FileDoneEvent,
28 FileStartEvent,
29 OcrStartEvent,
30 ProgressEvent,
31 SyncDoneEvent,
32)
34if TYPE_CHECKING:
35 from lilbee.data.types import SyncResult
37log = logging.getLogger(__name__)
39_ADD_EMBED_THROTTLE_SECONDS = 0.15
40"""Throttle EMBED reporter updates to avoid TaskBar update storms.
42The embed worker fires one EmbedEvent per sub-batch, which on a fast
43laptop can be dozens per second. The Task Center only repaints at 10 Hz
44anyway, so we coalesce here at the same cadence.
45"""
48def close_stream(stream: Any) -> None:
49 """Close a streaming iterator if it satisfies the ClosableIterator protocol."""
50 if isinstance(stream, ClosableIterator):
51 with contextlib.suppress(Exception):
52 stream.close()
55def _opener_argv(platform: str) -> list[str] | None:
56 """The platform's open-with-default-app command, or None to use the browser."""
57 if platform == "darwin":
58 return ["open"]
59 if platform.startswith("linux"):
60 return ["xdg-open"]
61 return None
64def open_local_file(href: str) -> None:
65 """Open a ``file:`` URL with the OS opener so it lands in the default app
66 for its type (an editor for markdown, a viewer for PDF), not a browser
67 rendering raw text. Platforms without a known opener fall back to the
68 webbrowser module."""
69 argv = _opener_argv(sys.platform)
70 if argv is None:
71 webbrowser.open(href)
72 return
73 path = url2pathname(urlparse(href).path)
74 try:
75 subprocess.run([*argv, path], check=False, timeout=10) # noqa: S603 - fixed opener command; path comes from lilbee's own store
76 except (OSError, subprocess.TimeoutExpired):
77 log.warning("Could not open source file: %s", path)
80def detail_for_batch_progress(data: BatchProgressEvent, in_flight: list[str]) -> str:
81 """Pick the user-facing detail label for a BATCH_PROGRESS tick.
83 Per-page rasterization (vision OCR) is the only producer that uses
84 BatchStatus.RASTERIZING; it emits an absolute path in data.file
85 which never matches the relative source name kept in in_flight, so
86 identity-based detection would never fire. Status-based dispatch is
87 the reliable discriminator between per-page and per-file ticks.
88 """
89 if data.status == BatchStatus.RASTERIZING:
90 return msg.ADD_PAGE_PROGRESS.format(
91 status=data.status.capitalize(), current=data.current, total=data.total
92 )
93 if in_flight:
94 return msg.ADD_SYNCING_FILE.format(file=in_flight[0])
95 return msg.ADD_FILE_DONE.format(file=data.file)
98_PREFERENCE_PREFIX = "pref:"
101@dataclass(frozen=True)
102class RememberOutcome:
103 """A /remember result: the toast message plus the notify severity to use."""
105 message: str
106 severity: str = "information"
109def remember_from_input(raw: str) -> RememberOutcome:
110 """Parse, gate, and store a ``/remember`` command; return the toast outcome.
112 Pure orchestration so the ``@work`` worker body stays a single call and the
113 parse/gate/store path is testable without a running TUI. A leading
114 ``pref:`` marks the text as an always-recalled preference; anything else is
115 stored as a fact.
116 """
117 from lilbee.app.memory import MEMORY_DISABLED_HINT, memory_enabled, remember
118 from lilbee.app.services import get_services
119 from lilbee.data.store import MemoryKind
121 if not memory_enabled():
122 return RememberOutcome(MEMORY_DISABLED_HINT, "warning")
124 text = raw.strip()
125 kind = MemoryKind.FACT
126 if text[: len(_PREFERENCE_PREFIX)].lower() == _PREFERENCE_PREFIX:
127 kind = MemoryKind.PREFERENCE
128 text = text[len(_PREFERENCE_PREFIX) :].strip()
129 if not text:
130 return RememberOutcome(msg.CMD_REMEMBER_USAGE, "warning")
132 if not get_services().embedder.embedding_available():
133 return RememberOutcome(msg.CMD_REMEMBER_NO_EMBED, "warning")
135 remember(text, kind=kind)
136 return RememberOutcome(msg.CMD_REMEMBER_SUCCESS.format(kind=kind.value))
139def unregister_added_roots(labels: list[str]) -> None:
140 """Un-register roots a /add invocation created, when its finished sync failed them.
142 Only the registry entries this invocation added are dropped, with the skip
143 records its sync wrote under them; the source bytes on disk and files the
144 user owns are never touched.
145 """
146 from lilbee.app.ingest import forget_roots
148 if labels:
149 forget_roots(labels)
152def add_indexed_anything(registered: list[str], result: SyncResult) -> bool:
153 """Whether any file under this add's registered roots reached the index.
155 Sync is global, so its added/updated/relocated lists can name files from
156 other sources; only names keyed under a registered label count. A directory
157 root keys files as ``label/relpath``; a single-file root keys as ``label``.
158 """
159 indexed = (*result.added, *result.updated, *result.relocated)
160 return any(
161 name == root or name.startswith(f"{root}/") for root in registered for name in indexed
162 )
165def _throttled_embed_tick(reporter: ProgressReporter) -> Callable[[EmbedEvent], None]:
166 """Return the throttled EMBED tick shared by the add/sync/import callbacks."""
167 last_update = 0.0
169 def _tick(data: EmbedEvent) -> None:
170 nonlocal last_update
171 now = time.monotonic()
172 if now - last_update < _ADD_EMBED_THROTTLE_SECONDS:
173 return
174 last_update = now
175 pct = int(data.chunk * 100 / data.total_chunks) if data.total_chunks else 0
176 reporter.update(pct, msg.SYNC_EMBEDDING.format(file=data.file), indeterminate=False)
178 return _tick
181def _show_tesseract_ocr(reporter: ProgressReporter, data: OcrStartEvent) -> None:
182 """Show that Tesseract is OCRing a file, until its first page completes."""
183 reporter.update(
184 0,
185 msg.SYNC_TESSERACT_OCR.format(total=data.total_pages, file=data.file),
186 indeterminate=True,
187 )
190def build_add_progress_callback(reporter: ProgressReporter) -> DetailedProgressCallback:
191 """Build the on_progress callback used by /add.
193 Tracks files in flight in start order so the displayed filename pins
194 to the oldest unfinished file (the pipeline runs files concurrently;
195 without pinning the label flips around the queue). EXTRACT surfaces
196 "extracted N pages" once per file so a 44MB scanned PDF doesn't read
197 as a hang; EMBED ticks per chunk, throttled to a steady cadence.
198 """
199 in_flight: list[str] = []
200 embed_tick = _throttled_embed_tick(reporter)
202 def on_progress(event_type: EventType, data: ProgressEvent) -> None:
203 reporter.check_cancelled()
204 if event_type == EventType.FILE_START and isinstance(data, FileStartEvent):
205 in_flight.append(data.file)
206 reporter.update(0, msg.ADD_SYNCING_FILE.format(file=in_flight[0]), indeterminate=True)
207 elif event_type == EventType.FILE_DONE and isinstance(data, FileDoneEvent):
208 with contextlib.suppress(ValueError):
209 in_flight.remove(data.file)
210 elif event_type == EventType.BATCH_PROGRESS and isinstance(data, BatchProgressEvent):
211 pct = (data.current / data.total * 100.0) if data.total else 0.0
212 reporter.update(pct, detail_for_batch_progress(data, in_flight), indeterminate=False)
213 elif event_type == EventType.EXTRACT and isinstance(data, ExtractEvent):
214 reporter.update(
215 0,
216 msg.SYNC_FILE_PROGRESS.format(
217 current=data.page, total=data.total_pages, file=data.file
218 ),
219 indeterminate=True,
220 )
221 elif event_type == EventType.OCR_START and isinstance(data, OcrStartEvent):
222 _show_tesseract_ocr(reporter, data)
223 elif event_type == EventType.EMBED and isinstance(data, EmbedEvent):
224 embed_tick(data)
225 elif event_type == EventType.SYNC_WAIT:
226 _show_sync_wait(reporter)
228 return on_progress
231def _show_sync_wait(reporter: ProgressReporter) -> None:
232 """Show on the task bar that this sync waits for another; the sync logs the full notice."""
233 reporter.update(0, msg.SYNC_WAITING, indeterminate=True)
236def build_sync_progress_callback(
237 reporter: ProgressReporter,
238) -> Callable[[EventType, ProgressEvent], None]:
239 """Return the on_progress shim used by ``_do_sync``.
241 EXTRACT mirrors the /add path: a 44MB scanned PDF needs a per-page
242 tick or the row reads as frozen.
243 """
244 embed_tick = _throttled_embed_tick(reporter)
246 def on_progress(event_type: EventType, data: ProgressEvent) -> None:
247 # Mirror /add: explicit cancel check on every event so a SYNC task
248 # cancelled mid-batch stops at the next progress tick instead of
249 # finishing the current file. update() also checks, but events
250 # without a reporter.update call (e.g. BATCH_PROGRESS in the
251 # ingest_stream path) would otherwise miss the cooperative checkpoint.
252 reporter.check_cancelled()
253 if event_type == EventType.FILE_START and isinstance(data, FileStartEvent):
254 pct = int((data.current_file - 1) * 100 / data.total_files)
255 status = msg.SYNC_FILE_PROGRESS.format(
256 current=data.current_file, total=data.total_files, file=data.file
257 )
258 reporter.update(pct, status, indeterminate=False)
259 elif event_type == EventType.FILE_DONE and isinstance(data, FileDoneEvent):
260 reporter.update(0, msg.SYNC_FILE_DONE.format(file=data.file), indeterminate=False)
261 elif event_type == EventType.EXTRACT and isinstance(data, ExtractEvent):
262 reporter.update(
263 0,
264 msg.SYNC_FILE_PROGRESS.format(
265 current=data.page, total=data.total_pages, file=data.file
266 ),
267 indeterminate=True,
268 )
269 elif event_type == EventType.OCR_START and isinstance(data, OcrStartEvent):
270 _show_tesseract_ocr(reporter, data)
271 elif event_type == EventType.EMBED and isinstance(data, EmbedEvent):
272 embed_tick(data)
273 elif event_type == EventType.SYNC_WAIT:
274 _show_sync_wait(reporter)
275 elif event_type == EventType.SYNC_DONE and isinstance(data, SyncDoneEvent):
276 total = data.added + data.updated + data.removed
277 reporter.update(100, msg.SYNC_STATUS_DONE.format(count=total), indeterminate=False)
279 return on_progress
282def build_import_progress_callback(reporter: ProgressReporter) -> DetailedProgressCallback:
283 """Build the on_progress callback used by /import (EMBED events only)."""
284 embed_tick = _throttled_embed_tick(reporter)
286 def on_progress(event_type: EventType, data: ProgressEvent) -> None:
287 reporter.check_cancelled()
288 if event_type == EventType.EMBED and isinstance(data, EmbedEvent):
289 embed_tick(data)
291 return on_progress