Coverage for src / lilbee / cli / tui / widgets / autocomplete.py: 100%
126 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-29 19:16 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-29 19:16 +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.cli.settings_map import SETTINGS_MAP
17from lilbee.cli.tui.app import DARK_THEMES
18from lilbee.cli.tui.command_registry import completion_names
19from lilbee.services import get_services
21log = logging.getLogger(__name__)
23_SLASH_COMMANDS = completion_names()
24_MAX_VISIBLE = 8 # max dropdown items shown at once
26_CSS_FILE = Path(__file__).parent / "autocomplete.tcss"
29def get_completions(text: str) -> list[str]:
30 """Return completion options for the current input text."""
31 if not text.startswith("/"):
32 return []
34 if " " not in text:
35 return [c for c in _SLASH_COMMANDS if c.startswith(text) and c != text]
37 cmd, _, partial = text.partition(" ")
38 cmd = cmd.lower()
39 return _get_arg_completions(cmd, partial)
42def _get_arg_completions(cmd: str, partial: str) -> list[str]:
43 """Get argument completions for a specific command."""
44 sources = _ARG_SOURCES.get(cmd)
45 if sources is None:
46 return []
47 if cmd == "/add":
48 return _path_options(partial)
49 options = sources()
50 if partial:
51 return [o for o in options if o.lower().startswith(partial.lower())]
52 return options
55def _model_options() -> list[str]:
56 try:
57 from lilbee.models import list_installed_models
59 return list_installed_models()
60 except Exception:
61 log.debug("Failed to list models for autocomplete", exc_info=True)
62 return []
65def _setting_options() -> list[str]:
66 return list(SETTINGS_MAP.keys())
69def _document_options() -> list[str]:
70 try:
71 return [s.get("filename", s.get("source", "")) for s in get_services().store.get_sources()]
72 except Exception:
73 log.debug("Failed to list documents for autocomplete", exc_info=True)
74 return []
77def _theme_options() -> list[str]:
78 return list(DARK_THEMES)
81def _path_options(partial: str = "") -> list[str]:
82 """Return filesystem completions for a partial path.
83 Handles relative paths, absolute paths, and ~ expansion.
84 Directories get a trailing / so the user knows to keep typing.
85 """
86 try:
87 expanded = Path(partial).expanduser() if partial else Path(".")
88 if partial and not expanded.is_dir():
89 parent = expanded.parent
90 prefix = expanded.name.lower()
91 else:
92 parent = expanded
93 prefix = ""
95 if not parent.is_dir():
96 return []
98 results: list[str] = []
99 for p in sorted(parent.iterdir()):
100 if p.name.startswith("."):
101 continue
102 if prefix and not p.name.lower().startswith(prefix):
103 continue
104 display = str(p) if partial and Path(partial) != Path(".") else p.name
105 if p.is_dir():
106 display = display.rstrip("/") + "/"
107 results.append(display)
108 if len(results) >= 20:
109 break
110 return results
111 except Exception:
112 log.debug("Failed to list paths for autocomplete", exc_info=True)
113 return []
116_ARG_SOURCES: dict[str, Callable[[], list[str]]] = {
117 "/model": _model_options,
118 "/set": _setting_options,
119 "/delete": _document_options,
120 "/remove": _model_options,
121 "/theme": _theme_options,
122 "/add": _path_options,
123}
126class CompletionOverlay(Vertical):
127 """Dropdown overlay showing completion options above the input."""
129 BINDINGS: ClassVar[list[BindingType]] = [
130 Binding("escape", "dismiss_overlay", show=False),
131 ]
133 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8")
135 def __init__(self, **kwargs: object) -> None:
136 super().__init__(**kwargs) # type: ignore[arg-type]
137 self._options: list[str] = []
138 self._index = 0
140 def compose(self) -> ComposeResult:
141 yield OptionList(id="completion-list")
143 def show_completions(self, options: list[str]) -> None:
144 """Populate and show the overlay."""
145 self._options = options[:_MAX_VISIBLE]
146 self._index = 0
147 ol = self.query_one("#completion-list", OptionList)
148 ol.clear_options()
149 for opt in self._options:
150 ol.add_option(Option(opt))
151 if self._options:
152 ol.highlighted = 0
153 self.display = True
154 else:
155 self.display = False
157 def cycle_next(self) -> str | None:
158 """Cycle to next option and return it."""
159 if not self._options:
160 return None
161 self._index = (self._index + 1) % len(self._options)
162 ol = self.query_one("#completion-list", OptionList)
163 ol.highlighted = self._index
164 return self._options[self._index]
166 def cycle_prev(self) -> str | None:
167 """Cycle to previous option and return it."""
168 if not self._options:
169 return None
170 self._index = (self._index - 1) % len(self._options)
171 ol = self.query_one("#completion-list", OptionList)
172 ol.highlighted = self._index
173 return self._options[self._index]
175 def get_current(self) -> str | None:
176 """Get the currently highlighted option."""
177 if not self._options or self._index >= len(self._options):
178 return None
179 return self._options[self._index]
181 def hide(self) -> None:
182 """Hide the overlay."""
183 self.display = False
184 self._options = []
186 @property
187 def is_visible(self) -> bool:
188 return bool(self.display) and bool(self._options)
190 def action_dismiss_overlay(self) -> None:
191 self.hide()