Coverage for src/lilbee/cli/tui/widgets/autocomplete.py: 100%
161 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +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.content import Content
14from textual.widgets import OptionList
15from textual.widgets.option_list import Option
17from lilbee.app.ingest import removable_names
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", "/export-chat"})
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 _indexed_names() -> list[str]:
118 """Indexed-source names in store order; 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 []
126def _document_options() -> list[str]:
127 """``/delete`` targets: indexed sources, then held-out sources not indexed."""
128 return removable_names(_indexed_names())
131def _theme_options() -> list[str]:
132 return list(DARK_THEMES)
135def _path_exists(partial: str) -> bool:
136 """True if *partial* resolves to an existing file or directory."""
137 try:
138 return Path(partial).expanduser().exists()
139 except Exception:
140 log.debug("Failed to check path existence for autocomplete", exc_info=True)
141 return False
144def _path_options(partial: str = "") -> list[str]:
145 """Return basename completions for the path segment being typed.
147 Handles relative paths, absolute paths, and ~ expansion. Only the final
148 segment is returned (the caller keeps whatever prefix the user typed, so
149 ``~/`` stays ``~/``); directories get a trailing ``/`` to invite descent.
150 """
151 try:
152 expanded = Path(partial).expanduser() if partial else Path(".")
153 if partial and not expanded.is_dir():
154 parent = expanded.parent
155 prefix = expanded.name.lower()
156 else:
157 parent = expanded
158 prefix = ""
160 if not parent.is_dir():
161 return []
163 results: list[str] = []
164 for p in sorted(parent.iterdir()):
165 if p.name.startswith("."):
166 continue
167 if prefix and not p.name.lower().startswith(prefix):
168 continue
169 results.append(p.name + "/" if p.is_dir() else p.name)
170 if len(results) >= _MAX_PATH_COMPLETIONS:
171 break
172 return results
173 except Exception:
174 log.debug("Failed to list paths for autocomplete", exc_info=True)
175 return []
178_PATH_SEPARATORS = ("/", "\\")
181def path_completion_prefix(partial: str) -> str:
182 """Directory prefix of *partial* up to and including the last path separator.
184 Splits on both ``/`` and ``\\`` so accepting an /add path completion keeps
185 the directory the user typed instead of collapsing to the basename. On
186 Windows the typed path uses backslashes, so a ``/``-only split would drop
187 the whole directory and turn ``C:\\dir\\file.md`` into ``file.md``.
188 """
189 cut = max(partial.rfind(sep) for sep in _PATH_SEPARATORS)
190 return partial[: cut + 1]
193def longest_common_prefix(values: list[str]) -> str:
194 """Return the longest string that prefixes every value (``""`` if none)."""
195 if not values:
196 return ""
197 shortest = min(values, key=len)
198 for i, ch in enumerate(shortest):
199 if any(v[i] != ch for v in values):
200 return shortest[:i]
201 return shortest
204_ARG_SOURCES: dict[str, Callable[[], list[str]]] = {
205 "/model": _model_options,
206 "/set": _setting_options,
207 "/delete": _document_options,
208 "/remove": _model_options,
209 "/theme": _theme_options,
210 "/add": _path_options,
211 "/import": _path_options,
212 "/export": _path_options,
213 "/export-chat": _path_options,
214}
217class CompletionOverlay(Vertical):
218 """Dropdown overlay showing completion options above the input."""
220 BINDINGS: ClassVar[list[BindingType]] = [
221 Binding("escape", "dismiss_overlay", show=False),
222 ]
224 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8")
226 def __init__(self, **kwargs: object) -> None:
227 super().__init__(**kwargs) # type: ignore[arg-type]
228 self._options: list[str] = []
230 def compose(self) -> ComposeResult:
231 yield ClampedOptionList(id="completion-list")
233 def show_completions(self, options: list[str]) -> None:
234 """Populate and show the overlay."""
235 self._options = options[:_MAX_VISIBLE]
236 ol = self.query_one("#completion-list", OptionList)
237 ol.clear_options()
238 for opt in self._options:
239 ol.add_option(Option(_option_prompt(opt)))
240 if self._options:
241 ol.highlighted = 0
242 self.display = True
243 else:
244 self.display = False
246 def _cycle(self, step: int) -> str | None:
247 """Move the OptionList cursor by *step* (wrapping) and return the option.
249 ``OptionList.highlighted`` is the single source of truth for the
250 cursor; a shadow index here drifted from it whenever the list moved
251 by other means (mouse hover, page keys).
252 """
253 if not self._options:
254 return None
255 ol = self.query_one("#completion-list", OptionList)
256 index = ((ol.highlighted or 0) + step) % len(self._options)
257 ol.highlighted = index
258 return self._options[index]
260 def cycle_next(self) -> str | None:
261 """Cycle to next option and return it."""
262 return self._cycle(1)
264 def cycle_prev(self) -> str | None:
265 """Cycle to previous option and return it."""
266 return self._cycle(-1)
268 def get_current(self) -> str | None:
269 """Get the currently highlighted option."""
270 if not self._options:
271 return None
272 index = self.query_one("#completion-list", OptionList).highlighted
273 if index is None or index >= len(self._options):
274 return None
275 return self._options[index]
277 @property
278 def options(self) -> list[str]:
279 """The currently shown completion options."""
280 return list(self._options)
282 def hide(self) -> None:
283 """Hide the overlay."""
284 self.display = False
285 self._options = []
287 @property
288 def is_visible(self) -> bool:
289 return bool(self.display) and bool(self._options)
291 def action_dismiss_overlay(self) -> None:
292 self.hide()