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

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.content import Content 

14from textual.widgets import OptionList 

15from textual.widgets.option_list import Option 

16 

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 

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", "/export-chat"}) 

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

124 

125 

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

127 """``/delete`` targets: indexed sources, then held-out sources not indexed.""" 

128 return removable_names(_indexed_names()) 

129 

130 

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

132 return list(DARK_THEMES) 

133 

134 

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 

142 

143 

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

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

146 

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

159 

160 if not parent.is_dir(): 

161 return [] 

162 

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

176 

177 

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

179 

180 

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

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

183 

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] 

191 

192 

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 

202 

203 

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} 

215 

216 

217class CompletionOverlay(Vertical): 

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

219 

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

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

222 ] 

223 

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

225 

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

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

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

229 

230 def compose(self) -> ComposeResult: 

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

232 

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 

245 

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

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

248 

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] 

259 

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

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

262 return self._cycle(1) 

263 

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

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

266 return self._cycle(-1) 

267 

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] 

276 

277 @property 

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

279 """The currently shown completion options.""" 

280 return list(self._options) 

281 

282 def hide(self) -> None: 

283 """Hide the overlay.""" 

284 self.display = False 

285 self._options = [] 

286 

287 @property 

288 def is_visible(self) -> bool: 

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

290 

291 def action_dismiss_overlay(self) -> None: 

292 self.hide()