complete.go 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. package dialog
  2. import (
  3. "log/slog"
  4. "sort"
  5. "strings"
  6. "github.com/charmbracelet/bubbles/v2/key"
  7. "github.com/charmbracelet/bubbles/v2/textarea"
  8. tea "github.com/charmbracelet/bubbletea/v2"
  9. "github.com/charmbracelet/lipgloss/v2"
  10. "github.com/charmbracelet/lipgloss/v2/compat"
  11. "github.com/lithammer/fuzzysearch/fuzzy"
  12. "github.com/muesli/reflow/truncate"
  13. "github.com/sst/opencode/internal/components/list"
  14. "github.com/sst/opencode/internal/styles"
  15. "github.com/sst/opencode/internal/theme"
  16. "github.com/sst/opencode/internal/util"
  17. )
  18. type CompletionItem struct {
  19. Title string
  20. Value string
  21. ProviderID string
  22. Raw any
  23. backgroundColor *compat.AdaptiveColor
  24. }
  25. type CompletionItemI interface {
  26. list.ListItem
  27. GetValue() string
  28. DisplayValue() string
  29. GetProviderID() string
  30. GetRaw() any
  31. }
  32. func (ci *CompletionItem) Render(selected bool, width int) string {
  33. t := theme.CurrentTheme()
  34. baseStyle := styles.NewStyle().Foreground(t.Text())
  35. truncatedStr := truncate.String(string(ci.DisplayValue()), uint(width-4))
  36. backgroundColor := t.BackgroundElement()
  37. if ci.backgroundColor != nil {
  38. backgroundColor = *ci.backgroundColor
  39. }
  40. itemStyle := baseStyle.
  41. Background(backgroundColor).
  42. Padding(0, 1)
  43. if selected {
  44. itemStyle = itemStyle.Foreground(t.Primary())
  45. }
  46. title := itemStyle.Render(truncatedStr)
  47. return title
  48. }
  49. func (ci *CompletionItem) DisplayValue() string {
  50. return ci.Title
  51. }
  52. func (ci *CompletionItem) GetValue() string {
  53. return ci.Value
  54. }
  55. func (ci *CompletionItem) GetProviderID() string {
  56. return ci.ProviderID
  57. }
  58. func (ci *CompletionItem) GetRaw() any {
  59. return ci.Raw
  60. }
  61. type CompletionItemOption func(*CompletionItem)
  62. func WithBackgroundColor(color compat.AdaptiveColor) CompletionItemOption {
  63. return func(ci *CompletionItem) {
  64. ci.backgroundColor = &color
  65. }
  66. }
  67. func NewCompletionItem(completionItem CompletionItem, opts ...CompletionItemOption) CompletionItemI {
  68. for _, opt := range opts {
  69. opt(&completionItem)
  70. }
  71. return &completionItem
  72. }
  73. type CompletionProvider interface {
  74. GetId() string
  75. GetChildEntries(query string) ([]CompletionItemI, error)
  76. GetEmptyMessage() string
  77. }
  78. type CompletionSelectedMsg struct {
  79. Item CompletionItemI
  80. SearchString string
  81. }
  82. type CompletionDialogCompleteItemMsg struct {
  83. Value string
  84. }
  85. type CompletionDialogCloseMsg struct{}
  86. type CompletionDialog interface {
  87. tea.Model
  88. tea.ViewModel
  89. SetWidth(width int)
  90. IsEmpty() bool
  91. }
  92. type completionDialogComponent struct {
  93. query string
  94. providers []CompletionProvider
  95. width int
  96. height int
  97. pseudoSearchTextArea textarea.Model
  98. list list.List[CompletionItemI]
  99. trigger string
  100. }
  101. type completionDialogKeyMap struct {
  102. Complete key.Binding
  103. Cancel key.Binding
  104. }
  105. var completionDialogKeys = completionDialogKeyMap{
  106. Complete: key.NewBinding(
  107. key.WithKeys("tab", "enter", "right"),
  108. ),
  109. Cancel: key.NewBinding(
  110. key.WithKeys(" ", "esc", "backspace", "ctrl+c"),
  111. ),
  112. }
  113. func (c *completionDialogComponent) Init() tea.Cmd {
  114. return nil
  115. }
  116. func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd {
  117. return func() tea.Msg {
  118. allItems := make([]CompletionItemI, 0)
  119. // Collect results from all providers
  120. for _, provider := range c.providers {
  121. items, err := provider.GetChildEntries(query)
  122. if err != nil {
  123. slog.Error(
  124. "Failed to get completion items",
  125. "provider",
  126. provider.GetId(),
  127. "error",
  128. err,
  129. )
  130. continue
  131. }
  132. allItems = append(allItems, items...)
  133. }
  134. // If there's a query, use fuzzy ranking to sort results
  135. if query != "" && len(allItems) > 0 {
  136. // Create a slice of display values for fuzzy matching
  137. displayValues := make([]string, len(allItems))
  138. for i, item := range allItems {
  139. displayValues[i] = item.DisplayValue()
  140. }
  141. // Get fuzzy matches with ranking
  142. matches := fuzzy.RankFindFold(query, displayValues)
  143. // Sort by score (best matches first)
  144. sort.Sort(matches)
  145. // Reorder items based on fuzzy ranking
  146. rankedItems := make([]CompletionItemI, 0, len(matches))
  147. for _, match := range matches {
  148. rankedItems = append(rankedItems, allItems[match.OriginalIndex])
  149. }
  150. return rankedItems
  151. }
  152. return allItems
  153. }
  154. }
  155. func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  156. var cmds []tea.Cmd
  157. switch msg := msg.(type) {
  158. case []CompletionItemI:
  159. c.list.SetItems(msg)
  160. case tea.KeyMsg:
  161. if c.pseudoSearchTextArea.Focused() {
  162. if !key.Matches(msg, completionDialogKeys.Complete) {
  163. var cmd tea.Cmd
  164. c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
  165. cmds = append(cmds, cmd)
  166. fullValue := c.pseudoSearchTextArea.Value()
  167. query := strings.TrimPrefix(fullValue, c.trigger)
  168. if query != c.query {
  169. c.query = query
  170. cmds = append(cmds, c.getAllCompletions(query))
  171. }
  172. u, cmd := c.list.Update(msg)
  173. c.list = u.(list.List[CompletionItemI])
  174. cmds = append(cmds, cmd)
  175. }
  176. switch {
  177. case key.Matches(msg, completionDialogKeys.Complete):
  178. item, i := c.list.GetSelectedItem()
  179. if i == -1 {
  180. return c, nil
  181. }
  182. return c, c.complete(item)
  183. case key.Matches(msg, completionDialogKeys.Cancel):
  184. value := c.pseudoSearchTextArea.Value()
  185. width := lipgloss.Width(value)
  186. triggerWidth := lipgloss.Width(c.trigger)
  187. // Only close on backspace when there are no characters left, unless we're back to just the trigger
  188. if msg.String() != "backspace" || (width <= triggerWidth && value != c.trigger) {
  189. return c, c.close()
  190. }
  191. }
  192. return c, tea.Batch(cmds...)
  193. } else {
  194. cmds = append(cmds, c.getAllCompletions(""))
  195. cmds = append(cmds, c.pseudoSearchTextArea.Focus())
  196. return c, tea.Batch(cmds...)
  197. }
  198. }
  199. return c, tea.Batch(cmds...)
  200. }
  201. func (c *completionDialogComponent) View() string {
  202. t := theme.CurrentTheme()
  203. baseStyle := styles.NewStyle().Foreground(t.Text())
  204. c.list.SetMaxWidth(c.width)
  205. return baseStyle.
  206. Padding(0, 0).
  207. Background(t.BackgroundElement()).
  208. BorderStyle(lipgloss.ThickBorder()).
  209. BorderLeft(true).
  210. BorderRight(true).
  211. BorderForeground(t.Border()).
  212. BorderBackground(t.Background()).
  213. Width(c.width).
  214. Render(c.list.View())
  215. }
  216. func (c *completionDialogComponent) SetWidth(width int) {
  217. c.width = width
  218. }
  219. func (c *completionDialogComponent) IsEmpty() bool {
  220. return c.list.IsEmpty()
  221. }
  222. func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
  223. value := c.pseudoSearchTextArea.Value()
  224. return tea.Batch(
  225. util.CmdHandler(CompletionSelectedMsg{
  226. SearchString: value,
  227. Item: item,
  228. }),
  229. c.close(),
  230. )
  231. }
  232. func (c *completionDialogComponent) close() tea.Cmd {
  233. c.pseudoSearchTextArea.Reset()
  234. c.pseudoSearchTextArea.Blur()
  235. return util.CmdHandler(CompletionDialogCloseMsg{})
  236. }
  237. func NewCompletionDialogComponent(
  238. trigger string,
  239. providers ...CompletionProvider,
  240. ) CompletionDialog {
  241. ti := textarea.New()
  242. ti.SetValue(trigger)
  243. // Use a generic empty message if we have multiple providers
  244. emptyMessage := "no matching items"
  245. if len(providers) == 1 {
  246. emptyMessage = providers[0].GetEmptyMessage()
  247. }
  248. li := list.NewListComponent(
  249. []CompletionItemI{},
  250. 7,
  251. emptyMessage,
  252. false,
  253. )
  254. c := &completionDialogComponent{
  255. query: "",
  256. providers: providers,
  257. pseudoSearchTextArea: ti,
  258. list: li,
  259. trigger: trigger,
  260. }
  261. // Load initial items from all providers
  262. go func() {
  263. allItems := make([]CompletionItemI, 0)
  264. for _, provider := range providers {
  265. items, err := provider.GetChildEntries("")
  266. if err != nil {
  267. slog.Error(
  268. "Failed to get completion items",
  269. "provider",
  270. provider.GetId(),
  271. "error",
  272. err,
  273. )
  274. continue
  275. }
  276. allItems = append(allItems, items...)
  277. }
  278. li.SetItems(allItems)
  279. }()
  280. return c
  281. }