complete.go 7.2 KB

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