complete.go 8.1 KB

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