complete.go 7.4 KB

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