Coverage for src/lilbee/cli/tui/widgets/autocomplete.py: 100%
172 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"""Autocomplete dropdown overlay for the chat input."""
3from __future__ import annotations
5import heapq
6import logging
7import os
8from collections.abc import Callable
9from pathlib import Path
10from typing import ClassVar
12from textual.app import ComposeResult
13from textual.binding import Binding, BindingType
14from textual.containers import Vertical
15from textual.content import Content
16from textual.widgets import OptionList
17from textual.widgets.option_list import Option
19from lilbee.app.ingest import removable_names
20from lilbee.app.services import get_services
21from lilbee.app.settings import _is_settable
22from lilbee.app.settings_map import SETTINGS_MAP
23from lilbee.app.themes import DARK_THEMES
24from lilbee.cli.tui.command_registry import (
25 ANALYZE_OFF_ARG,
26 ANALYZE_REPORT_ARG,
27 COMMANDS,
28 completion_names,
29)
30from lilbee.cli.tui.widgets.clamped_option_list import ClampedOptionList
31from lilbee.core.profile_files import ProfileStore
33log = logging.getLogger(__name__)
35_SLASH_COMMANDS = completion_names()
36_COMMAND_HELP: dict[str, str] = {
37 name: cmd.help_text for cmd in COMMANDS for name in (cmd.name, *cmd.aliases)
38}
41def _option_prompt(value: str) -> Content:
42 """Render a dropdown row: bare *value*, plus dim registry help for commands."""
43 help_text = _COMMAND_HELP.get(value, "")
44 if not help_text:
45 return Content(value)
46 return Content.assemble(value, Content.styled(f" {help_text}", "$text-muted"))
49_MAX_VISIBLE = 8 # max dropdown items shown at once
50# Hard cap on path completions surfaced for path-argument commands so a deep
51# directory doesn't stall the dropdown rebuild.
52_MAX_PATH_COMPLETIONS = 20
54# Commands whose argument is a filesystem path. They share _path_options and
55# the path-specific accept rules (typed-directory prefix kept, existing-path
56# collapse).
57PATH_ARG_COMMANDS = frozenset({"/add", "/import", "/export", "/export-chat", "/analyze"})
58# Words a path-argument command also takes, offered ahead of the paths.
59PATH_ARG_WORDS: dict[str, tuple[str, ...]] = {"/analyze": (ANALYZE_OFF_ARG, ANALYZE_REPORT_ARG)}
60# Commands whose argument is an installed model. Listing the models probes the
61# configured local servers over HTTP, so the chat screen lists them on a thread.
62MODEL_ARG_COMMANDS = frozenset({"/model", "/remove"})
64_CSS_FILE = Path(__file__).parent / "autocomplete.tcss"
67def reads_a_folder(text: str) -> bool:
68 """Whether the completions for *text* list a folder on disk (a path-argument command)."""
69 cmd, space, _ = text.partition(" ")
70 return bool(space) and cmd.lower() in PATH_ARG_COMMANDS
73def get_completions(text: str) -> list[str]:
74 """Return completion options for the current input text."""
75 if not text.startswith("/"):
76 return []
78 if " " not in text:
79 return [c for c in _SLASH_COMMANDS if c.startswith(text) and c != text]
81 cmd, _, partial = text.partition(" ")
82 cmd = cmd.lower()
83 return _get_arg_completions(cmd, partial)
86def _get_arg_completions(cmd: str, partial: str) -> list[str]:
87 """Get argument completions for a specific command.
89 Drops the option that exactly equals what the user has typed so a
90 fully-typed argument collapses the dropdown and lets Enter submit,
91 mirroring the command-discovery rule for slash commands.
92 """
93 sources = ARG_SOURCES.get(cmd)
94 if sources is None:
95 return []
96 if cmd in PATH_ARG_COMMANDS:
97 # A fully-typed existing path (no trailing separator) should submit on
98 # Enter rather than keep offering completions, so collapse the dropdown.
99 # Without this a complete directory path lists its contents forever and
100 # Enter accepts a child instead of submitting. A trailing separator still
101 # descends to list the directory's contents.
102 if partial and not partial.endswith(_PATH_SEPARATORS) and _path_exists(partial):
103 return []
104 words = [w for w in PATH_ARG_WORDS.get(cmd, ()) if w.startswith(partial.lower())]
105 # _path_options already prefix-filters against the basename and returns
106 # bare segment names (not the typed prefix), so the generic startswith
107 # filter below would wrongly wipe them.
108 options = words + _path_options(partial)
109 return [o for o in options if o.lower() != partial.lower()]
110 return _match_args(sources(), partial)
113def _match_args(options: list[str], partial: str) -> list[str]:
114 """Filter *options* against the typed *partial*, dropping an exact match."""
115 if partial:
116 # Substring match so a model's human name ("smol") finds its full
117 # ref without the HF org; prefix matches keep first place.
118 low = partial.lower()
119 prefixed = [o for o in options if o.lower().startswith(low)]
120 contained = [o for o in options if low in o.lower() and not o.lower().startswith(low)]
121 options = prefixed + contained
122 return [o for o in options if o.lower() != partial.lower()]
125def reads_models(text: str) -> bool:
126 """Whether the completions for *text* list the installed models (a model-argument command)."""
127 cmd, space, _ = text.partition(" ")
128 return bool(space) and cmd.lower() in MODEL_ARG_COMMANDS
131def model_completions(text: str, names: list[str]) -> list[str]:
132 """Completions for a model-argument *text* from the already listed model *names*."""
133 return _match_args(names, text.partition(" ")[2])
136def list_model_options() -> list[str]:
137 """The installed model names; can probe a local server over HTTP, so keep it off the loop."""
138 try:
139 from lilbee.modelhub.models import list_installed_models
141 return list_installed_models()
142 except Exception:
143 log.debug("Failed to list models for autocomplete", exc_info=True)
144 return []
147def _setting_options() -> list[str]:
148 # Only settable keys, in map order: a non-writable entry (e.g. wiki_dir)
149 # would be offered then refused by /set.
150 return [k for k in SETTINGS_MAP if _is_settable(k)]
153def _indexed_names() -> list[str]:
154 """Indexed-source names in store order; empty on any store error."""
155 try:
156 return [s.get("filename", s.get("source", "")) for s in get_services().store.get_sources()]
157 except Exception:
158 log.debug("Failed to list documents for autocomplete", exc_info=True)
159 return []
162def _document_options() -> list[str]:
163 """``/delete`` targets: indexed sources, then held-out sources not indexed."""
164 return removable_names(_indexed_names())
167def _profile_options() -> list[str]:
168 return ProfileStore().scan().usable_names()
171def _theme_options() -> list[str]:
172 return list(DARK_THEMES)
175def _path_exists(partial: str) -> bool:
176 """True if *partial* resolves to an existing file or directory."""
177 try:
178 return Path(partial).expanduser().exists()
179 except Exception:
180 log.debug("Failed to check path existence for autocomplete", exc_info=True)
181 return False
184def _path_options(partial: str = "") -> list[str]:
185 """Return basename completions for the path segment being typed.
187 Handles relative paths, absolute paths, and ~ expansion. Only the final
188 segment is returned (the caller keeps whatever prefix the user typed, so
189 ``~/`` stays ``~/``); directories get a trailing ``/`` to invite descent.
190 """
191 try:
192 expanded = Path(partial).expanduser() if partial else Path(".")
193 if partial and not expanded.is_dir():
194 parent = expanded.parent
195 prefix = expanded.name.lower()
196 else:
197 parent = expanded
198 prefix = ""
200 if not parent.is_dir():
201 return []
203 # One pass over the folder keeps the 20 smallest names in a heap, so a
204 # folder with thousands of entries is neither sorted nor stat-ed in full.
205 with os.scandir(parent) as entries:
206 names = heapq.nsmallest(
207 _MAX_PATH_COMPLETIONS,
208 (
209 e.name
210 for e in entries
211 if not e.name.startswith(".") and e.name.lower().startswith(prefix)
212 ),
213 # The key the old sorted(Path) used: Windows compares names case-folded.
214 key=os.path.normcase,
215 )
216 return [name + "/" if (parent / name).is_dir() else name for name in names]
217 except Exception:
218 log.debug("Failed to list paths for autocomplete", exc_info=True)
219 return []
222_PATH_SEPARATORS = ("/", "\\")
225def path_completion_prefix(partial: str) -> str:
226 """Directory prefix of *partial* up to and including the last path separator.
228 Splits on both ``/`` and ``\\`` so accepting an /add path completion keeps
229 the directory the user typed instead of collapsing to the basename. On
230 Windows the typed path uses backslashes, so a ``/``-only split would drop
231 the whole directory and turn ``C:\\dir\\file.md`` into ``file.md``.
232 """
233 cut = max(partial.rfind(sep) for sep in _PATH_SEPARATORS)
234 return partial[: cut + 1]
237def longest_common_prefix(values: list[str]) -> str:
238 """Return the longest string that prefixes every value (``""`` if none)."""
239 if not values:
240 return ""
241 shortest = min(values, key=len)
242 for i, ch in enumerate(shortest):
243 if any(v[i] != ch for v in values):
244 return shortest[:i]
245 return shortest
248ARG_SOURCES: dict[str, Callable[[], list[str]]] = {
249 "/model": list_model_options,
250 "/set": _setting_options,
251 "/delete": _document_options,
252 "/remove": list_model_options,
253 "/theme": _theme_options,
254 "/profile": _profile_options,
255 "/add": _path_options,
256 "/analyze": _path_options,
257 "/import": _path_options,
258 "/export": _path_options,
259 "/export-chat": _path_options,
260}
263class CompletionOverlay(Vertical):
264 """Dropdown overlay showing completion options above the input."""
266 BINDINGS: ClassVar[list[BindingType]] = [
267 Binding("escape", "dismiss_overlay", show=False),
268 ]
270 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8")
272 def __init__(self, **kwargs: object) -> None:
273 super().__init__(**kwargs) # type: ignore[arg-type]
274 self._options: list[str] = []
276 def compose(self) -> ComposeResult:
277 yield ClampedOptionList(id="completion-list")
279 def show_completions(self, options: list[str]) -> None:
280 """Populate and show the overlay."""
281 self._options = options[:_MAX_VISIBLE]
282 ol = self.query_one("#completion-list", OptionList)
283 ol.clear_options()
284 for opt in self._options:
285 ol.add_option(Option(_option_prompt(opt)))
286 if self._options:
287 ol.highlighted = 0
288 self.display = True
289 else:
290 self.display = False
292 def _cycle(self, step: int) -> str | None:
293 """Move the OptionList cursor by *step* (wrapping) and return the option.
295 ``OptionList.highlighted`` is the single source of truth for the
296 cursor; a shadow index here drifted from it whenever the list moved
297 by other means (mouse hover, page keys).
298 """
299 if not self._options:
300 return None
301 ol = self.query_one("#completion-list", OptionList)
302 index = ((ol.highlighted or 0) + step) % len(self._options)
303 ol.highlighted = index
304 return self._options[index]
306 def cycle_next(self) -> str | None:
307 """Cycle to next option and return it."""
308 return self._cycle(1)
310 def cycle_prev(self) -> str | None:
311 """Cycle to previous option and return it."""
312 return self._cycle(-1)
314 def get_current(self) -> str | None:
315 """Get the currently highlighted option."""
316 if not self._options:
317 return None
318 index = self.query_one("#completion-list", OptionList).highlighted
319 if index is None or index >= len(self._options):
320 return None
321 return self._options[index]
323 @property
324 def options(self) -> list[str]:
325 """The currently shown completion options."""
326 return list(self._options)
328 def hide(self) -> None:
329 """Hide the overlay."""
330 self.display = False
331 self._options = []
333 @property
334 def is_visible(self) -> bool:
335 return bool(self.display) and bool(self._options)
337 def action_dismiss_overlay(self) -> None:
338 self.hide()