Coverage for src/lilbee/cli/tui/widgets/autocomplete.py: 100%

172 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +0000

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

2 

3from __future__ import annotations 

4 

5import heapq 

6import logging 

7import os 

8from collections.abc import Callable 

9from pathlib import Path 

10from typing import ClassVar 

11 

12from textual.app import ComposeResult 

13from textual.binding import Binding, BindingType 

14from textual.containers import Vertical 

15from textual.content import Content 

16from textual.widgets import OptionList 

17from textual.widgets.option_list import Option 

18 

19from lilbee.app.ingest import removable_names 

20from lilbee.app.services import get_services 

21from lilbee.app.settings import _is_settable 

22from lilbee.app.settings_map import SETTINGS_MAP 

23from lilbee.app.themes import DARK_THEMES 

24from lilbee.cli.tui.command_registry import ( 

25 ANALYZE_OFF_ARG, 

26 ANALYZE_REPORT_ARG, 

27 COMMANDS, 

28 completion_names, 

29) 

30from lilbee.cli.tui.widgets.clamped_option_list import ClampedOptionList 

31from lilbee.core.profile_files import ProfileStore 

32 

33log = logging.getLogger(__name__) 

34 

35_SLASH_COMMANDS = completion_names() 

36_COMMAND_HELP: dict[str, str] = { 

37 name: cmd.help_text for cmd in COMMANDS for name in (cmd.name, *cmd.aliases) 

38} 

39 

40 

41def _option_prompt(value: str) -> Content: 

42 """Render a dropdown row: bare *value*, plus dim registry help for commands.""" 

43 help_text = _COMMAND_HELP.get(value, "") 

44 if not help_text: 

45 return Content(value) 

46 return Content.assemble(value, Content.styled(f" {help_text}", "$text-muted")) 

47 

48 

49_MAX_VISIBLE = 8 # max dropdown items shown at once 

50# Hard cap on path completions surfaced for path-argument commands so a deep 

51# directory doesn't stall the dropdown rebuild. 

52_MAX_PATH_COMPLETIONS = 20 

53 

54# Commands whose argument is a filesystem path. They share _path_options and 

55# the path-specific accept rules (typed-directory prefix kept, existing-path 

56# collapse). 

57PATH_ARG_COMMANDS = frozenset({"/add", "/import", "/export", "/export-chat", "/analyze"}) 

58# Words a path-argument command also takes, offered ahead of the paths. 

59PATH_ARG_WORDS: dict[str, tuple[str, ...]] = {"/analyze": (ANALYZE_OFF_ARG, ANALYZE_REPORT_ARG)} 

60# Commands whose argument is an installed model. Listing the models probes the 

61# configured local servers over HTTP, so the chat screen lists them on a thread. 

62MODEL_ARG_COMMANDS = frozenset({"/model", "/remove"}) 

63 

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

65 

66 

67def reads_a_folder(text: str) -> bool: 

68 """Whether the completions for *text* list a folder on disk (a path-argument command).""" 

69 cmd, space, _ = text.partition(" ") 

70 return bool(space) and cmd.lower() in PATH_ARG_COMMANDS 

71 

72 

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

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

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

76 return [] 

77 

78 if " " not in text: 

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

80 

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

82 cmd = cmd.lower() 

83 return _get_arg_completions(cmd, partial) 

84 

85 

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

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

88 

89 Drops the option that exactly equals what the user has typed so a 

90 fully-typed argument collapses the dropdown and lets Enter submit, 

91 mirroring the command-discovery rule for slash commands. 

92 """ 

93 sources = ARG_SOURCES.get(cmd) 

94 if sources is None: 

95 return [] 

96 if cmd in PATH_ARG_COMMANDS: 

97 # A fully-typed existing path (no trailing separator) should submit on 

98 # Enter rather than keep offering completions, so collapse the dropdown. 

99 # Without this a complete directory path lists its contents forever and 

100 # Enter accepts a child instead of submitting. A trailing separator still 

101 # descends to list the directory's contents. 

102 if partial and not partial.endswith(_PATH_SEPARATORS) and _path_exists(partial): 

103 return [] 

104 words = [w for w in PATH_ARG_WORDS.get(cmd, ()) if w.startswith(partial.lower())] 

105 # _path_options already prefix-filters against the basename and returns 

106 # bare segment names (not the typed prefix), so the generic startswith 

107 # filter below would wrongly wipe them. 

108 options = words + _path_options(partial) 

109 return [o for o in options if o.lower() != partial.lower()] 

110 return _match_args(sources(), partial) 

111 

112 

113def _match_args(options: list[str], partial: str) -> list[str]: 

114 """Filter *options* against the typed *partial*, dropping an exact match.""" 

115 if partial: 

116 # Substring match so a model's human name ("smol") finds its full 

117 # ref without the HF org; prefix matches keep first place. 

118 low = partial.lower() 

119 prefixed = [o for o in options if o.lower().startswith(low)] 

120 contained = [o for o in options if low in o.lower() and not o.lower().startswith(low)] 

121 options = prefixed + contained 

122 return [o for o in options if o.lower() != partial.lower()] 

123 

124 

125def reads_models(text: str) -> bool: 

126 """Whether the completions for *text* list the installed models (a model-argument command).""" 

127 cmd, space, _ = text.partition(" ") 

128 return bool(space) and cmd.lower() in MODEL_ARG_COMMANDS 

129 

130 

131def model_completions(text: str, names: list[str]) -> list[str]: 

132 """Completions for a model-argument *text* from the already listed model *names*.""" 

133 return _match_args(names, text.partition(" ")[2]) 

134 

135 

136def list_model_options() -> list[str]: 

137 """The installed model names; can probe a local server over HTTP, so keep it off the loop.""" 

138 try: 

139 from lilbee.modelhub.models import list_installed_models 

140 

141 return list_installed_models() 

142 except Exception: 

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

144 return [] 

145 

146 

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

148 # Only settable keys, in map order: a non-writable entry (e.g. wiki_dir) 

149 # would be offered then refused by /set. 

150 return [k for k in SETTINGS_MAP if _is_settable(k)] 

151 

152 

153def _indexed_names() -> list[str]: 

154 """Indexed-source names in store order; empty on any store error.""" 

155 try: 

156 return [s.get("filename", s.get("source", "")) for s in get_services().store.get_sources()] 

157 except Exception: 

158 log.debug("Failed to list documents for autocomplete", exc_info=True) 

159 return [] 

160 

161 

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

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

164 return removable_names(_indexed_names()) 

165 

166 

167def _profile_options() -> list[str]: 

168 return ProfileStore().scan().usable_names() 

169 

170 

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

172 return list(DARK_THEMES) 

173 

174 

175def _path_exists(partial: str) -> bool: 

176 """True if *partial* resolves to an existing file or directory.""" 

177 try: 

178 return Path(partial).expanduser().exists() 

179 except Exception: 

180 log.debug("Failed to check path existence for autocomplete", exc_info=True) 

181 return False 

182 

183 

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

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

186 

187 Handles relative paths, absolute paths, and ~ expansion. Only the final 

188 segment is returned (the caller keeps whatever prefix the user typed, so 

189 ``~/`` stays ``~/``); directories get a trailing ``/`` to invite descent. 

190 """ 

191 try: 

192 expanded = Path(partial).expanduser() if partial else Path(".") 

193 if partial and not expanded.is_dir(): 

194 parent = expanded.parent 

195 prefix = expanded.name.lower() 

196 else: 

197 parent = expanded 

198 prefix = "" 

199 

200 if not parent.is_dir(): 

201 return [] 

202 

203 # One pass over the folder keeps the 20 smallest names in a heap, so a 

204 # folder with thousands of entries is neither sorted nor stat-ed in full. 

205 with os.scandir(parent) as entries: 

206 names = heapq.nsmallest( 

207 _MAX_PATH_COMPLETIONS, 

208 ( 

209 e.name 

210 for e in entries 

211 if not e.name.startswith(".") and e.name.lower().startswith(prefix) 

212 ), 

213 # The key the old sorted(Path) used: Windows compares names case-folded. 

214 key=os.path.normcase, 

215 ) 

216 return [name + "/" if (parent / name).is_dir() else name for name in names] 

217 except Exception: 

218 log.debug("Failed to list paths for autocomplete", exc_info=True) 

219 return [] 

220 

221 

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

223 

224 

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

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

227 

228 Splits on both ``/`` and ``\\`` so accepting an /add path completion keeps 

229 the directory the user typed instead of collapsing to the basename. On 

230 Windows the typed path uses backslashes, so a ``/``-only split would drop 

231 the whole directory and turn ``C:\\dir\\file.md`` into ``file.md``. 

232 """ 

233 cut = max(partial.rfind(sep) for sep in _PATH_SEPARATORS) 

234 return partial[: cut + 1] 

235 

236 

237def longest_common_prefix(values: list[str]) -> str: 

238 """Return the longest string that prefixes every value (``""`` if none).""" 

239 if not values: 

240 return "" 

241 shortest = min(values, key=len) 

242 for i, ch in enumerate(shortest): 

243 if any(v[i] != ch for v in values): 

244 return shortest[:i] 

245 return shortest 

246 

247 

248ARG_SOURCES: dict[str, Callable[[], list[str]]] = { 

249 "/model": list_model_options, 

250 "/set": _setting_options, 

251 "/delete": _document_options, 

252 "/remove": list_model_options, 

253 "/theme": _theme_options, 

254 "/profile": _profile_options, 

255 "/add": _path_options, 

256 "/analyze": _path_options, 

257 "/import": _path_options, 

258 "/export": _path_options, 

259 "/export-chat": _path_options, 

260} 

261 

262 

263class CompletionOverlay(Vertical): 

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

265 

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

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

268 ] 

269 

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

271 

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

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

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

275 

276 def compose(self) -> ComposeResult: 

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

278 

279 def show_completions(self, options: list[str]) -> None: 

280 """Populate and show the overlay.""" 

281 self._options = options[:_MAX_VISIBLE] 

282 ol = self.query_one("#completion-list", OptionList) 

283 ol.clear_options() 

284 for opt in self._options: 

285 ol.add_option(Option(_option_prompt(opt))) 

286 if self._options: 

287 ol.highlighted = 0 

288 self.display = True 

289 else: 

290 self.display = False 

291 

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

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

294 

295 ``OptionList.highlighted`` is the single source of truth for the 

296 cursor; a shadow index here drifted from it whenever the list moved 

297 by other means (mouse hover, page keys). 

298 """ 

299 if not self._options: 

300 return None 

301 ol = self.query_one("#completion-list", OptionList) 

302 index = ((ol.highlighted or 0) + step) % len(self._options) 

303 ol.highlighted = index 

304 return self._options[index] 

305 

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

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

308 return self._cycle(1) 

309 

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

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

312 return self._cycle(-1) 

313 

314 def get_current(self) -> str | None: 

315 """Get the currently highlighted option.""" 

316 if not self._options: 

317 return None 

318 index = self.query_one("#completion-list", OptionList).highlighted 

319 if index is None or index >= len(self._options): 

320 return None 

321 return self._options[index] 

322 

323 @property 

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

325 """The currently shown completion options.""" 

326 return list(self._options) 

327 

328 def hide(self) -> None: 

329 """Hide the overlay.""" 

330 self.display = False 

331 self._options = [] 

332 

333 @property 

334 def is_visible(self) -> bool: 

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

336 

337 def action_dismiss_overlay(self) -> None: 

338 self.hide()