complete.go 7.8 KB

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