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

1"""Autocomplete dropdown overlay for the chat input.""" 

2 

3from __future__ import annotations 

4 

5import functools 

6import logging 

7from collections.abc import Callable 

8from pathlib import Path 

9from typing import ClassVar 

10 

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 

17 

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 

24 

25log = logging.getLogger(__name__) 

26 

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} 

31 

32 

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")) 

39 

40 

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 

45 

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"}) 

50 

51_CSS_FILE = Path(__file__).parent / "autocomplete.tcss" 

52 

53 

54def get_completions(text: str) -> list[str]: 

55 """Return completion options for the current input text.""" 

56 if not text.startswith("/"): 

57 return [] 

58 

59 if " " not in text: 

60 return [c for c in _SLASH_COMMANDS if c.startswith(text) and c != text] 

61 

62 cmd, _, partial = text.partition(" ") 

63 cmd = cmd.lower() 

64 return _get_arg_completions(cmd, partial) 

65 

66 

67def _get_arg_completions(cmd: str, partial: str) -> list[str]: 

68 """Get argument completions for a specific command. 

69 

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()] 

99 

100 

101def _model_options() -> list[str]: 

102 try: 

103 from lilbee.modelhub.models import list_installed_models 

104 

105 return list_installed_models() 

106 except Exception: 

107 log.debug("Failed to list models for autocomplete", exc_info=True) 

108 return [] 

109 

110 

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)] 

115 

116 

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 [] 

124 

125 

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()) 

132 

133 

134def _document_options() -> list[str]: 

135 return list(_document_options_cached()) 

136 

137 

138def invalidate_document_cache() -> None: 

139 """Drop the cached document list; the next Tab refetches from the store.""" 

140 _document_options_cached.cache_clear() 

141 

142 

143def _theme_options() -> list[str]: 

144 return list(DARK_THEMES) 

145 

146 

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 

154 

155 

156def _path_options(partial: str = "") -> list[str]: 

157 """Return basename completions for the path segment being typed. 

158 

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 = "" 

171 

172 if not parent.is_dir(): 

173 return [] 

174 

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 [] 

188 

189 

190_PATH_SEPARATORS = ("/", "\\") 

191 

192 

193def path_completion_prefix(partial: str) -> str: 

194 """Directory prefix of *partial* up to and including the last path separator. 

195 

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] 

203 

204 

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 

214 

215 

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} 

226 

227 

228class CompletionOverlay(Vertical): 

229 """Dropdown overlay showing completion options above the input.""" 

230 

231 BINDINGS: ClassVar[list[BindingType]] = [ 

232 Binding("escape", "dismiss_overlay", show=False), 

233 ] 

234 

235 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8") 

236 

237 def __init__(self, **kwargs: object) -> None: 

238 super().__init__(**kwargs) # type: ignore[arg-type] 

239 self._options: list[str] = [] 

240 

241 def compose(self) -> ComposeResult: 

242 yield ClampedOptionList(id="completion-list") 

243 

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 

256 

257 def _cycle(self, step: int) -> str | None: 

258 """Move the OptionList cursor by *step* (wrapping) and return the option. 

259 

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] 

270 

271 def cycle_next(self) -> str | None: 

272 """Cycle to next option and return it.""" 

273 return self._cycle(1) 

274 

275 def cycle_prev(self) -> str | None: 

276 """Cycle to previous option and return it.""" 

277 return self._cycle(-1) 

278 

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] 

287 

288 @property 

289 def options(self) -> list[str]: 

290 """The currently shown completion options.""" 

291 return list(self._options) 

292 

293 def hide(self) -> None: 

294 """Hide the overlay.""" 

295 self.display = False 

296 self._options = [] 

297 

298 @property 

299 def is_visible(self) -> bool: 

300 return bool(self.display) and bool(self._options) 

301 

302 def action_dismiss_overlay(self) -> None: 

303 self.hide()