complete.go 8.4 KB

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