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

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

2 

3from __future__ import annotations 

4 

5import logging 

6from collections.abc import Callable 

7from pathlib import Path 

8from typing import ClassVar 

9 

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 

15 

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 

20 

21log = logging.getLogger(__name__) 

22 

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 

28 

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

30 

31 

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 

37 

38 

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

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

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

42 return [] 

43 

44 if " " not in text: 

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

46 

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

48 cmd = cmd.lower() 

49 return _get_arg_completions(cmd, partial) 

50 

51 

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

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

54 

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

73 

74 

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

76 try: 

77 from lilbee.modelhub.models import list_installed_models 

78 

79 return list_installed_models() 

80 except Exception: 

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

82 return [] 

83 

84 

85def _setting_options() -> list[str]: 

86 return list(SETTINGS_MAP.keys()) 

87 

88 

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 

101 

102 

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 

107 

108 

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

110 return list(DARK_THEMES) 

111 

112 

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

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

115 

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

128 

129 if not parent.is_dir(): 

130 return [] 

131 

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

145 

146 

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

148 

149 

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

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

152 

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] 

160 

161 

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 

171 

172 

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} 

181 

182 

183class CompletionOverlay(Vertical): 

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

185 

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

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

188 ] 

189 

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

191 

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

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

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

195 self._index = 0 

196 

197 def compose(self) -> ComposeResult: 

198 yield OptionList(id="completion-list") 

199 

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 

213 

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] 

222 

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] 

231 

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] 

237 

238 @property 

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

240 """The currently shown completion options.""" 

241 return list(self._options) 

242 

243 def hide(self) -> None: 

244 """Hide the overlay.""" 

245 self.display = False 

246 self._options = [] 

247 

248 @property 

249 def is_visible(self) -> bool: 

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

251 

252 def action_dismiss_overlay(self) -> None: 

253 self.hide()