complete.go 7.4 KB

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