Coverage for src / lilbee / cli / tui / widgets / autocomplete.py: 100%
146 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-06-28 01:01 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-06-28 01:01 +0000
1"""Autocomplete dropdown overlay for the chat input."""
3from __future__ import annotations
5import logging
6from collections.abc import Callable
7from pathlib import Path
8from typing import ClassVar
10from textual.app import ComposeResult
11from textual.binding import Binding, BindingType
12from textual.containers import Vertical
13from textual.widgets import OptionList
14from textual.widgets.option_list import Option
16from lilbee.app.services import get_services
17from lilbee.app.settings_map import SETTINGS_MAP
18from lilbee.app.themes import DARK_THEMES
19from lilbee.cli.tui.command_registry import completion_names
21log = logging.getLogger(__name__)
23_SLASH_COMMANDS = completion_names()
24_MAX_VISIBLE = 8 # max dropdown items shown at once
25# Hard cap on path completions surfaced for /add so a deep directory doesn't
26# stall the dropdown rebuild.
27_MAX_PATH_COMPLETIONS = 20
29_CSS_FILE = Path(__file__).parent / "autocomplete.tcss"
32# Cached document list for ``/delete`` and ``/reset`` Tab completion.
33# Invalidated by ``invalidate_document_cache`` on document mutations so
34# Tab returns the live set. Order is stable across reads because the
35# dropdown renders in fetch order.
36_doc_cache: list[str] | None = None
39def get_completions(text: str) -> list[str]:
40 """Return completion options for the current input text."""
41 if not text.startswith("/"):
42 return []
44 if " " not in text:
45 return [c for c in _SLASH_COMMANDS if c.startswith(text) and c != text]
47 cmd, _, partial = text.partition(" ")
48 cmd = cmd.lower()
49 return _get_arg_completions(cmd, partial)
52def _get_arg_completions(cmd: str, partial: str) -> list[str]:
53 """Get argument completions for a specific command.
55 Drops the option that exactly equals what the user has typed so a
56 fully-typed argument collapses the dropdown and lets Enter submit,
57 mirroring the command-discovery rule for slash commands.
58 """
59 sources = _ARG_SOURCES.get(cmd)
60 if sources is None:
61 return []
62 if cmd == "/add":
63 # _path_options already prefix-filters against the basename and returns
64 # bare segment names (not the typed prefix), so the generic startswith
65 # filter below would wrongly wipe them.
66 options = _path_options(partial)
67 else:
68 options = sources()
69 if partial:
70 low = partial.lower()
71 options = [o for o in options if o.lower().startswith(low)]
72 return [o for o in options if o.lower() != partial.lower()]
75def _model_options() -> list[str]:
76 try:
77 from lilbee.modelhub.models import list_installed_models
79 return list_installed_models()
80 except Exception:
81 log.debug("Failed to list models for autocomplete", exc_info=True)
82 return []
85def _setting_options() -> list[str]:
86 return list(SETTINGS_MAP.keys())
89def _document_options() -> list[str]:
90 global _doc_cache
91 if _doc_cache is not None:
92 return _doc_cache
93 try:
94 _doc_cache = [
95 s.get("filename", s.get("source", "")) for s in get_services().store.get_sources()
96 ]
97 except Exception:
98 log.debug("Failed to list documents for autocomplete", exc_info=True)
99 _doc_cache = []
100 return _doc_cache
103def invalidate_document_cache() -> None:
104 """Drop the cached document list; the next Tab refetches from the store."""
105 global _doc_cache
106 _doc_cache = None
109def _theme_options() -> list[str]:
110 return list(DARK_THEMES)
113def _path_options(partial: str = "") -> list[str]:
114 """Return basename completions for the path segment being typed.
116 Handles relative paths, absolute paths, and ~ expansion. Only the final
117 segment is returned (the caller keeps whatever prefix the user typed, so
118 ``~/`` stays ``~/``); directories get a trailing ``/`` to invite descent.
119 """
120 try:
121 expanded = Path(partial).expanduser() if partial else Path(".")
122 if partial and not expanded.is_dir():
123 parent = expanded.parent
124 prefix = expanded.name.lower()
125 else:
126 parent = expanded
127 prefix = ""
129 if not parent.is_dir():
130 return []
132 results: list[str] = []
133 for p in sorted(parent.iterdir()):
134 if p.name.startswith("."):
135 continue
136 if prefix and not p.name.lower().startswith(prefix):
137 continue
138 results.append(p.name + "/" if p.is_dir() else p.name)
139 if len(results) >= _MAX_PATH_COMPLETIONS:
140 break
141 return results
142 except Exception:
143 log.debug("Failed to list paths for autocomplete", exc_info=True)
144 return []
147_PATH_SEPARATORS = ("/", "\\")
150def path_completion_prefix(partial: str) -> str:
151 """Directory prefix of *partial* up to and including the last path separator.
153 Splits on both ``/`` and ``\\`` so accepting an /add path completion keeps
154 the directory the user typed instead of collapsing to the basename. On
155 Windows the typed path uses backslashes, so a ``/``-only split would drop
156 the whole directory and turn ``C:\\dir\\file.md`` into ``file.md``.
157 """
158 cut = max(partial.rfind(sep) for sep in _PATH_SEPARATORS)
159 return partial[: cut + 1]
162def longest_common_prefix(values: list[str]) -> str:
163 """Return the longest string that prefixes every value (``""`` if none)."""
164 if not values:
165 return ""
166 shortest = min(values, key=len)
167 for i, ch in enumerate(shortest):
168 if any(v[i] != ch for v in values):
169 return shortest[:i]
170 return shortest
173_ARG_SOURCES: dict[str, Callable[[], list[str]]] = {
174 "/model": _model_options,
175 "/set": _setting_options,
176 "/delete": _document_options,
177 "/remove": _model_options,
178 "/theme": _theme_options,
179 "/add": _path_options,
180}
183class CompletionOverlay(Vertical):
184 """Dropdown overlay showing completion options above the input."""
186 BINDINGS: ClassVar[list[BindingType]] = [
187 Binding("escape", "dismiss_overlay", show=False),
188 ]
190 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8")
192 def __init__(self, **kwargs: object) -> None:
193 super().__init__(**kwargs) # type: ignore[arg-type]
194 self._options: list[str] = []
195 self._index = 0
197 def compose(self) -> ComposeResult:
198 yield OptionList(id="completion-list")
200 def show_completions(self, options: list[str]) -> None:
201 """Populate and show the overlay."""
202 self._options = options[:_MAX_VISIBLE]
203 self._index = 0
204 ol = self.query_one("#completion-list", OptionList)
205 ol.clear_options()
206 for opt in self._options:
207 ol.add_option(Option(opt))
208 if self._options:
209 ol.highlighted = 0
210 self.display = True
211 else:
212 self.display = False
214 def cycle_next(self) -> str | None:
215 """Cycle to next option and return it."""
216 if not self._options:
217 return None
218 self._index = (self._index + 1) % len(self._options)
219 ol = self.query_one("#completion-list", OptionList)
220 ol.highlighted = self._index
221 return self._options[self._index]
223 def cycle_prev(self) -> str | None:
224 """Cycle to previous option and return it."""
225 if not self._options:
226 return None
227 self._index = (self._index - 1) % len(self._options)
228 ol = self.query_one("#completion-list", OptionList)
229 ol.highlighted = self._index
230 return self._options[self._index]
232 def get_current(self) -> str | None:
233 """Get the currently highlighted option."""
234 if not self._options or self._index >= len(self._options):
235 return None
236 return self._options[self._index]
238 @property
239 def options(self) -> list[str]:
240 """The currently shown completion options."""
241 return list(self._options)
243 def hide(self) -> None:
244 """Hide the overlay."""
245 self.display = False
246 self._options = []
248 @property
249 def is_visible(self) -> bool:
250 return bool(self.display) and bool(self._options)
252 def action_dismiss_overlay(self) -> None:
253 self.hide()