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

126 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-04-29 19:16 +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.cli.settings_map import SETTINGS_MAP 

17from lilbee.cli.tui.app import DARK_THEMES 

18from lilbee.cli.tui.command_registry import completion_names 

19from lilbee.services import get_services 

20 

21log = logging.getLogger(__name__) 

22 

23_SLASH_COMMANDS = completion_names() 

24_MAX_VISIBLE = 8 # max dropdown items shown at once 

25 

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

27 

28 

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

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

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

32 return [] 

33 

34 if " " not in text: 

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

36 

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

38 cmd = cmd.lower() 

39 return _get_arg_completions(cmd, partial) 

40 

41 

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

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

44 sources = _ARG_SOURCES.get(cmd) 

45 if sources is None: 

46 return [] 

47 if cmd == "/add": 

48 return _path_options(partial) 

49 options = sources() 

50 if partial: 

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

52 return options 

53 

54 

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

56 try: 

57 from lilbee.models import list_installed_models 

58 

59 return list_installed_models() 

60 except Exception: 

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

62 return [] 

63 

64 

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

66 return list(SETTINGS_MAP.keys()) 

67 

68 

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

70 try: 

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

72 except Exception: 

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

74 return [] 

75 

76 

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

78 return list(DARK_THEMES) 

79 

80 

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

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

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

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

85 """ 

86 try: 

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

88 if partial and not expanded.is_dir(): 

89 parent = expanded.parent 

90 prefix = expanded.name.lower() 

91 else: 

92 parent = expanded 

93 prefix = "" 

94 

95 if not parent.is_dir(): 

96 return [] 

97 

98 results: list[str] = [] 

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

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

101 continue 

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

103 continue 

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

105 if p.is_dir(): 

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

107 results.append(display) 

108 if len(results) >= 20: 

109 break 

110 return results 

111 except Exception: 

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

113 return [] 

114 

115 

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

117 "/model": _model_options, 

118 "/set": _setting_options, 

119 "/delete": _document_options, 

120 "/remove": _model_options, 

121 "/theme": _theme_options, 

122 "/add": _path_options, 

123} 

124 

125 

126class CompletionOverlay(Vertical): 

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

128 

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

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

131 ] 

132 

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

134 

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

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

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

138 self._index = 0 

139 

140 def compose(self) -> ComposeResult: 

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

142 

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

144 """Populate and show the overlay.""" 

145 self._options = options[:_MAX_VISIBLE] 

146 self._index = 0 

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

148 ol.clear_options() 

149 for opt in self._options: 

150 ol.add_option(Option(opt)) 

151 if self._options: 

152 ol.highlighted = 0 

153 self.display = True 

154 else: 

155 self.display = False 

156 

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

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

159 if not self._options: 

160 return None 

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

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

163 ol.highlighted = self._index 

164 return self._options[self._index] 

165 

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

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

168 if not self._options: 

169 return None 

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

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

172 ol.highlighted = self._index 

173 return self._options[self._index] 

174 

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

176 """Get the currently highlighted option.""" 

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

178 return None 

179 return self._options[self._index] 

180 

181 def hide(self) -> None: 

182 """Hide the overlay.""" 

183 self.display = False 

184 self._options = [] 

185 

186 @property 

187 def is_visible(self) -> bool: 

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

189 

190 def action_dismiss_overlay(self) -> None: 

191 self.hide()