Coverage for src / lilbee / cli / tui / widgets / autocomplete.py: 100%
133 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +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.cli.settings_map import SETTINGS_MAP
18from lilbee.cli.tui.app 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."""
54 sources = _ARG_SOURCES.get(cmd)
55 if sources is None:
56 return []
57 if cmd == "/add":
58 return _path_options(partial)
59 options = sources()
60 if partial:
61 return [o for o in options if o.lower().startswith(partial.lower())]
62 return options
65def _model_options() -> list[str]:
66 try:
67 from lilbee.modelhub.models import list_installed_models
69 return list_installed_models()
70 except Exception:
71 log.debug("Failed to list models for autocomplete", exc_info=True)
72 return []
75def _setting_options() -> list[str]:
76 return list(SETTINGS_MAP.keys())
79def _document_options() -> list[str]:
80 global _doc_cache
81 if _doc_cache is not None:
82 return _doc_cache
83 try:
84 _doc_cache = [
85 s.get("filename", s.get("source", "")) for s in get_services().store.get_sources()
86 ]
87 except Exception:
88 log.debug("Failed to list documents for autocomplete", exc_info=True)
89 _doc_cache = []
90 return _doc_cache
93def invalidate_document_cache() -> None:
94 """Drop the cached document list; the next Tab refetches from the store."""
95 global _doc_cache
96 _doc_cache = None
99def _theme_options() -> list[str]:
100 return list(DARK_THEMES)
103def _path_options(partial: str = "") -> list[str]:
104 """Return filesystem completions for a partial path.
105 Handles relative paths, absolute paths, and ~ expansion.
106 Directories get a trailing / so the user knows to keep typing.
107 """
108 try:
109 expanded = Path(partial).expanduser() if partial else Path(".")
110 if partial and not expanded.is_dir():
111 parent = expanded.parent
112 prefix = expanded.name.lower()
113 else:
114 parent = expanded
115 prefix = ""
117 if not parent.is_dir():
118 return []
120 results: list[str] = []
121 for p in sorted(parent.iterdir()):
122 if p.name.startswith("."):
123 continue
124 if prefix and not p.name.lower().startswith(prefix):
125 continue
126 display = str(p) if partial and Path(partial) != Path(".") else p.name
127 if p.is_dir():
128 display = display.rstrip("/") + "/"
129 results.append(display)
130 if len(results) >= _MAX_PATH_COMPLETIONS:
131 break
132 return results
133 except Exception:
134 log.debug("Failed to list paths for autocomplete", exc_info=True)
135 return []
138_ARG_SOURCES: dict[str, Callable[[], list[str]]] = {
139 "/model": _model_options,
140 "/set": _setting_options,
141 "/delete": _document_options,
142 "/remove": _model_options,
143 "/theme": _theme_options,
144 "/add": _path_options,
145}
148class CompletionOverlay(Vertical):
149 """Dropdown overlay showing completion options above the input."""
151 BINDINGS: ClassVar[list[BindingType]] = [
152 Binding("escape", "dismiss_overlay", show=False),
153 ]
155 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8")
157 def __init__(self, **kwargs: object) -> None:
158 super().__init__(**kwargs) # type: ignore[arg-type]
159 self._options: list[str] = []
160 self._index = 0
162 def compose(self) -> ComposeResult:
163 yield OptionList(id="completion-list")
165 def show_completions(self, options: list[str]) -> None:
166 """Populate and show the overlay."""
167 self._options = options[:_MAX_VISIBLE]
168 self._index = 0
169 ol = self.query_one("#completion-list", OptionList)
170 ol.clear_options()
171 for opt in self._options:
172 ol.add_option(Option(opt))
173 if self._options:
174 ol.highlighted = 0
175 self.display = True
176 else:
177 self.display = False
179 def cycle_next(self) -> str | None:
180 """Cycle to next option and return it."""
181 if not self._options:
182 return None
183 self._index = (self._index + 1) % len(self._options)
184 ol = self.query_one("#completion-list", OptionList)
185 ol.highlighted = self._index
186 return self._options[self._index]
188 def cycle_prev(self) -> str | None:
189 """Cycle to previous option and return it."""
190 if not self._options:
191 return None
192 self._index = (self._index - 1) % len(self._options)
193 ol = self.query_one("#completion-list", OptionList)
194 ol.highlighted = self._index
195 return self._options[self._index]
197 def get_current(self) -> str | None:
198 """Get the currently highlighted option."""
199 if not self._options or self._index >= len(self._options):
200 return None
201 return self._options[self._index]
203 def hide(self) -> None:
204 """Hide the overlay."""
205 self.display = False
206 self._options = []
208 @property
209 def is_visible(self) -> bool:
210 return bool(self.display) and bool(self._options)
212 def action_dismiss_overlay(self) -> None:
213 self.hide()