Coverage for src/lilbee/cli/tui/widgets/autocomplete.py: 100%
166 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"""Autocomplete dropdown overlay for the chat input."""
3from __future__ import annotations
5import functools
6import logging
7from collections.abc import Callable
8from pathlib import Path
9from typing import ClassVar
11from textual.app import ComposeResult
12from textual.binding import Binding, BindingType
13from textual.containers import Vertical
14from textual.content import Content
15from textual.widgets import OptionList
16from textual.widgets.option_list import Option
18from lilbee.app.services import get_services
19from lilbee.app.settings import _is_settable
20from lilbee.app.settings_map import SETTINGS_MAP
21from lilbee.app.themes import DARK_THEMES
22from lilbee.cli.tui.command_registry import COMMANDS, completion_names
23from lilbee.cli.tui.widgets.clamped_option_list import ClampedOptionList
25log = logging.getLogger(__name__)
27_SLASH_COMMANDS = completion_names()
28_COMMAND_HELP: dict[str, str] = {
29 name: cmd.help_text for cmd in COMMANDS for name in (cmd.name, *cmd.aliases)
30}
33def _option_prompt(value: str) -> Content:
34 """Render a dropdown row: bare *value*, plus dim registry help for commands."""
35 help_text = _COMMAND_HELP.get(value, "")
36 if not help_text:
37 return Content(value)
38 return Content.assemble(value, Content.styled(f" {help_text}", "$text-muted"))
41_MAX_VISIBLE = 8 # max dropdown items shown at once
42# Hard cap on path completions surfaced for path-argument commands so a deep
43# directory doesn't stall the dropdown rebuild.
44_MAX_PATH_COMPLETIONS = 20
46# Commands whose argument is a filesystem path. They share _path_options and
47# the path-specific accept rules (typed-directory prefix kept, existing-path
48# collapse).
49PATH_ARG_COMMANDS = frozenset({"/add", "/import", "/export"})
51_CSS_FILE = Path(__file__).parent / "autocomplete.tcss"
54def get_completions(text: str) -> list[str]:
55 """Return completion options for the current input text."""
56 if not text.startswith("/"):
57 return []
59 if " " not in text:
60 return [c for c in _SLASH_COMMANDS if c.startswith(text) and c != text]
62 cmd, _, partial = text.partition(" ")
63 cmd = cmd.lower()
64 return _get_arg_completions(cmd, partial)
67def _get_arg_completions(cmd: str, partial: str) -> list[str]:
68 """Get argument completions for a specific command.
70 Drops the option that exactly equals what the user has typed so a
71 fully-typed argument collapses the dropdown and lets Enter submit,
72 mirroring the command-discovery rule for slash commands.
73 """
74 sources = _ARG_SOURCES.get(cmd)
75 if sources is None:
76 return []
77 if cmd in PATH_ARG_COMMANDS:
78 # A fully-typed existing path (no trailing separator) should submit on
79 # Enter rather than keep offering completions, so collapse the dropdown.
80 # Without this a complete directory path lists its contents forever and
81 # Enter accepts a child instead of submitting. A trailing separator still
82 # descends to list the directory's contents.
83 if partial and not partial.endswith(_PATH_SEPARATORS) and _path_exists(partial):
84 return []
85 # _path_options already prefix-filters against the basename and returns
86 # bare segment names (not the typed prefix), so the generic startswith
87 # filter below would wrongly wipe them.
88 options = _path_options(partial)
89 else:
90 options = sources()
91 if partial:
92 # Substring match so a model's human name ("smol") finds its full
93 # ref without the HF org; prefix matches keep first place.
94 low = partial.lower()
95 prefixed = [o for o in options if o.lower().startswith(low)]
96 contained = [o for o in options if low in o.lower() and not o.lower().startswith(low)]
97 options = prefixed + contained
98 return [o for o in options if o.lower() != partial.lower()]
101def _model_options() -> list[str]:
102 try:
103 from lilbee.modelhub.models import list_installed_models
105 return list_installed_models()
106 except Exception:
107 log.debug("Failed to list models for autocomplete", exc_info=True)
108 return []
111def _setting_options() -> list[str]:
112 # Only settable keys, in map order: a non-writable entry (e.g. wiki_dir)
113 # would be offered then refused by /set.
114 return [k for k in SETTINGS_MAP if _is_settable(k)]
117def _fetch_document_names() -> list[str]:
118 """Uncached indexed-source names; empty on any store error."""
119 try:
120 return [s.get("filename", s.get("source", "")) for s in get_services().store.get_sources()]
121 except Exception:
122 log.debug("Failed to list documents for autocomplete", exc_info=True)
123 return []
126@functools.lru_cache(maxsize=1)
127def _document_options_cached() -> tuple[str, ...]:
128 # Cached for ``/delete`` and ``/reset`` Tab completion; cleared by
129 # invalidate_document_cache on document mutations. Order is stable (fetch
130 # order) so the dropdown is deterministic.
131 return tuple(_fetch_document_names())
134def _document_options() -> list[str]:
135 return list(_document_options_cached())
138def invalidate_document_cache() -> None:
139 """Drop the cached document list; the next Tab refetches from the store."""
140 _document_options_cached.cache_clear()
143def _theme_options() -> list[str]:
144 return list(DARK_THEMES)
147def _path_exists(partial: str) -> bool:
148 """True if *partial* resolves to an existing file or directory."""
149 try:
150 return Path(partial).expanduser().exists()
151 except Exception:
152 log.debug("Failed to check path existence for autocomplete", exc_info=True)
153 return False
156def _path_options(partial: str = "") -> list[str]:
157 """Return basename completions for the path segment being typed.
159 Handles relative paths, absolute paths, and ~ expansion. Only the final
160 segment is returned (the caller keeps whatever prefix the user typed, so
161 ``~/`` stays ``~/``); directories get a trailing ``/`` to invite descent.
162 """
163 try:
164 expanded = Path(partial).expanduser() if partial else Path(".")
165 if partial and not expanded.is_dir():
166 parent = expanded.parent
167 prefix = expanded.name.lower()
168 else:
169 parent = expanded
170 prefix = ""
172 if not parent.is_dir():
173 return []
175 results: list[str] = []
176 for p in sorted(parent.iterdir()):
177 if p.name.startswith("."):
178 continue
179 if prefix and not p.name.lower().startswith(prefix):
180 continue
181 results.append(p.name + "/" if p.is_dir() else p.name)
182 if len(results) >= _MAX_PATH_COMPLETIONS:
183 break
184 return results
185 except Exception:
186 log.debug("Failed to list paths for autocomplete", exc_info=True)
187 return []
190_PATH_SEPARATORS = ("/", "\\")
193def path_completion_prefix(partial: str) -> str:
194 """Directory prefix of *partial* up to and including the last path separator.
196 Splits on both ``/`` and ``\\`` so accepting an /add path completion keeps
197 the directory the user typed instead of collapsing to the basename. On
198 Windows the typed path uses backslashes, so a ``/``-only split would drop
199 the whole directory and turn ``C:\\dir\\file.md`` into ``file.md``.
200 """
201 cut = max(partial.rfind(sep) for sep in _PATH_SEPARATORS)
202 return partial[: cut + 1]
205def longest_common_prefix(values: list[str]) -> str:
206 """Return the longest string that prefixes every value (``""`` if none)."""
207 if not values:
208 return ""
209 shortest = min(values, key=len)
210 for i, ch in enumerate(shortest):
211 if any(v[i] != ch for v in values):
212 return shortest[:i]
213 return shortest
216_ARG_SOURCES: dict[str, Callable[[], list[str]]] = {
217 "/model": _model_options,
218 "/set": _setting_options,
219 "/delete": _document_options,
220 "/remove": _model_options,
221 "/theme": _theme_options,
222 "/add": _path_options,
223 "/import": _path_options,
224 "/export": _path_options,
225}
228class CompletionOverlay(Vertical):
229 """Dropdown overlay showing completion options above the input."""
231 BINDINGS: ClassVar[list[BindingType]] = [
232 Binding("escape", "dismiss_overlay", show=False),
233 ]
235 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8")
237 def __init__(self, **kwargs: object) -> None:
238 super().__init__(**kwargs) # type: ignore[arg-type]
239 self._options: list[str] = []
241 def compose(self) -> ComposeResult:
242 yield ClampedOptionList(id="completion-list")
244 def show_completions(self, options: list[str]) -> None:
245 """Populate and show the overlay."""
246 self._options = options[:_MAX_VISIBLE]
247 ol = self.query_one("#completion-list", OptionList)
248 ol.clear_options()
249 for opt in self._options:
250 ol.add_option(Option(_option_prompt(opt)))
251 if self._options:
252 ol.highlighted = 0
253 self.display = True
254 else:
255 self.display = False
257 def _cycle(self, step: int) -> str | None:
258 """Move the OptionList cursor by *step* (wrapping) and return the option.
260 ``OptionList.highlighted`` is the single source of truth for the
261 cursor; a shadow index here drifted from it whenever the list moved
262 by other means (mouse hover, page keys).
263 """
264 if not self._options:
265 return None
266 ol = self.query_one("#completion-list", OptionList)
267 index = ((ol.highlighted or 0) + step) % len(self._options)
268 ol.highlighted = index
269 return self._options[index]
271 def cycle_next(self) -> str | None:
272 """Cycle to next option and return it."""
273 return self._cycle(1)
275 def cycle_prev(self) -> str | None:
276 """Cycle to previous option and return it."""
277 return self._cycle(-1)
279 def get_current(self) -> str | None:
280 """Get the currently highlighted option."""
281 if not self._options:
282 return None
283 index = self.query_one("#completion-list", OptionList).highlighted
284 if index is None or index >= len(self._options):
285 return None
286 return self._options[index]
288 @property
289 def options(self) -> list[str]:
290 """The currently shown completion options."""
291 return list(self._options)
293 def hide(self) -> None:
294 """Hide the overlay."""
295 self.display = False
296 self._options = []
298 @property
299 def is_visible(self) -> bool:
300 return bool(self.display) and bool(self._options)
302 def action_dismiss_overlay(self) -> None:
303 self.hide()