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

133 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-05-15 20:55 +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.cli.settings_map import SETTINGS_MAP 

18from lilbee.cli.tui.app 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 sources = _ARG_SOURCES.get(cmd) 

55 if sources is None: 

56 return [] 

57 if cmd == "/add": 

58 return _path_options(partial) 

59 options = sources() 

60 if partial: 

61 return [o for o in options if o.lower().startswith(partial.lower())] 

62 return options 

63 

64 

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

66 try: 

67 from lilbee.modelhub.models import list_installed_models 

68 

69 return list_installed_models() 

70 except Exception: 

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

72 return [] 

73 

74 

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

76 return list(SETTINGS_MAP.keys()) 

77 

78 

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

80 global _doc_cache 

81 if _doc_cache is not None: 

82 return _doc_cache 

83 try: 

84 _doc_cache = [ 

85 s.get("filename", s.get("source", "")) for s in get_services().store.get_sources() 

86 ] 

87 except Exception: 

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

89 _doc_cache = [] 

90 return _doc_cache 

91 

92 

93def invalidate_document_cache() -> None: 

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

95 global _doc_cache 

96 _doc_cache = None 

97 

98 

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

100 return list(DARK_THEMES) 

101 

102 

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

104 """Return filesystem completions for a partial path. 

105 Handles relative paths, absolute paths, and ~ expansion. 

106 Directories get a trailing / so the user knows to keep typing. 

107 """ 

108 try: 

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

110 if partial and not expanded.is_dir(): 

111 parent = expanded.parent 

112 prefix = expanded.name.lower() 

113 else: 

114 parent = expanded 

115 prefix = "" 

116 

117 if not parent.is_dir(): 

118 return [] 

119 

120 results: list[str] = [] 

121 for p in sorted(parent.iterdir()): 

122 if p.name.startswith("."): 

123 continue 

124 if prefix and not p.name.lower().startswith(prefix): 

125 continue 

126 display = str(p) if partial and Path(partial) != Path(".") else p.name 

127 if p.is_dir(): 

128 display = display.rstrip("/") + "/" 

129 results.append(display) 

130 if len(results) >= _MAX_PATH_COMPLETIONS: 

131 break 

132 return results 

133 except Exception: 

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

135 return [] 

136 

137 

138_ARG_SOURCES: dict[str, Callable[[], list[str]]] = { 

139 "/model": _model_options, 

140 "/set": _setting_options, 

141 "/delete": _document_options, 

142 "/remove": _model_options, 

143 "/theme": _theme_options, 

144 "/add": _path_options, 

145} 

146 

147 

148class CompletionOverlay(Vertical): 

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

150 

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

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

153 ] 

154 

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

156 

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

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

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

160 self._index = 0 

161 

162 def compose(self) -> ComposeResult: 

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

164 

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

166 """Populate and show the overlay.""" 

167 self._options = options[:_MAX_VISIBLE] 

168 self._index = 0 

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

170 ol.clear_options() 

171 for opt in self._options: 

172 ol.add_option(Option(opt)) 

173 if self._options: 

174 ol.highlighted = 0 

175 self.display = True 

176 else: 

177 self.display = False 

178 

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

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

181 if not self._options: 

182 return None 

183 self._index = (self._index + 1) % len(self._options) 

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

185 ol.highlighted = self._index 

186 return self._options[self._index] 

187 

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

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

190 if not self._options: 

191 return None 

192 self._index = (self._index - 1) % len(self._options) 

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

194 ol.highlighted = self._index 

195 return self._options[self._index] 

196 

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

198 """Get the currently highlighted option.""" 

199 if not self._options or self._index >= len(self._options): 

200 return None 

201 return self._options[self._index] 

202 

203 def hide(self) -> None: 

204 """Hide the overlay.""" 

205 self.display = False 

206 self._options = [] 

207 

208 @property 

209 def is_visible(self) -> bool: 

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

211 

212 def action_dismiss_overlay(self) -> None: 

213 self.hide()