complete.go 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. package dialog
  2. import (
  3. "log/slog"
  4. "strings"
  5. "github.com/charmbracelet/bubbles/v2/key"
  6. "github.com/charmbracelet/bubbles/v2/textarea"
  7. tea "github.com/charmbracelet/bubbletea/v2"
  8. "github.com/charmbracelet/lipgloss/v2"
  9. "github.com/sst/opencode/internal/components/list"
  10. "github.com/sst/opencode/internal/styles"
  11. "github.com/sst/opencode/internal/theme"
  12. "github.com/sst/opencode/internal/util"
  13. )
  14. type CompletionItem struct {
  15. Title string
  16. Value string
  17. }
  18. type CompletionItemI interface {
  19. list.ListItem
  20. GetValue() string
  21. DisplayValue() string
  22. }
  23. func (ci *CompletionItem) Render(selected bool, width int) string {
  24. t := theme.CurrentTheme()
  25. baseStyle := styles.NewStyle().Foreground(t.Text())
  26. itemStyle := baseStyle.
  27. Background(t.BackgroundElement()).
  28. Width(width).
  29. Padding(0, 1)
  30. if selected {
  31. itemStyle = itemStyle.Foreground(t.Primary())
  32. }
  33. title := itemStyle.Render(
  34. ci.DisplayValue(),
  35. )
  36. return title
  37. }
  38. func (ci *CompletionItem) DisplayValue() string {
  39. return ci.Title
  40. }
  41. func (ci *CompletionItem) GetValue() string {
  42. return ci.Value
  43. }
  44. func NewCompletionItem(completionItem CompletionItem) CompletionItemI {
  45. return &completionItem
  46. }
  47. type CompletionProvider interface {
  48. GetId() string
  49. GetChildEntries(query string) ([]CompletionItemI, error)
  50. GetEmptyMessage() string
  51. }
  52. type CompletionSelectedMsg struct {
  53. SearchString string
  54. CompletionValue string
  55. ProviderID string
  56. }
  57. type CompletionDialogCompleteItemMsg struct {
  58. Value string
  59. }
  60. type CompletionDialogCloseMsg struct{}
  61. type CompletionDialog interface {
  62. tea.Model
  63. tea.ViewModel
  64. SetWidth(width int)
  65. IsEmpty() bool
  66. }
  67. type completionDialogComponent struct {
  68. query string
  69. completionProvider CompletionProvider
  70. width int
  71. height int
  72. pseudoSearchTextArea textarea.Model
  73. list list.List[CompletionItemI]
  74. trigger string
  75. }
  76. type completionDialogKeyMap struct {
  77. Complete key.Binding
  78. Cancel key.Binding
  79. }
  80. var completionDialogKeys = completionDialogKeyMap{
  81. Complete: key.NewBinding(
  82. key.WithKeys("tab", "enter", "right"),
  83. ),
  84. Cancel: key.NewBinding(
  85. key.WithKeys(" ", "esc", "backspace", "ctrl+c"),
  86. ),
  87. }
  88. func (c *completionDialogComponent) Init() tea.Cmd {
  89. return nil
  90. }
  91. func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  92. var cmds []tea.Cmd
  93. switch msg := msg.(type) {
  94. case []CompletionItemI:
  95. c.list.SetItems(msg)
  96. case tea.KeyMsg:
  97. if c.pseudoSearchTextArea.Focused() {
  98. if !key.Matches(msg, completionDialogKeys.Complete) {
  99. var cmd tea.Cmd
  100. c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
  101. cmds = append(cmds, cmd)
  102. fullValue := c.pseudoSearchTextArea.Value()
  103. query := strings.TrimPrefix(fullValue, c.trigger)
  104. if query != c.query {
  105. c.query = query
  106. cmd = func() tea.Msg {
  107. items, err := c.completionProvider.GetChildEntries(query)
  108. if err != nil {
  109. slog.Error("Failed to get completion items", "error", err)
  110. }
  111. return items
  112. }
  113. cmds = append(cmds, cmd)
  114. }
  115. u, cmd := c.list.Update(msg)
  116. c.list = u.(list.List[CompletionItemI])
  117. cmds = append(cmds, cmd)
  118. }
  119. switch {
  120. case key.Matches(msg, completionDialogKeys.Complete):
  121. item, i := c.list.GetSelectedItem()
  122. if i == -1 {
  123. return c, nil
  124. }
  125. return c, c.complete(item)
  126. case key.Matches(msg, completionDialogKeys.Cancel):
  127. // Only close on backspace when there are no characters left, unless we're back to just the trigger
  128. value := c.pseudoSearchTextArea.Value()
  129. if msg.String() != "backspace" || (len(value) <= len(c.trigger) && value != c.trigger) {
  130. return c, c.close()
  131. }
  132. }
  133. return c, tea.Batch(cmds...)
  134. } else {
  135. cmd := func() tea.Msg {
  136. items, err := c.completionProvider.GetChildEntries("")
  137. if err != nil {
  138. slog.Error("Failed to get completion items", "error", err)
  139. }
  140. return items
  141. }
  142. cmds = append(cmds, cmd)
  143. cmds = append(cmds, c.pseudoSearchTextArea.Focus())
  144. return c, tea.Batch(cmds...)
  145. }
  146. }
  147. return c, tea.Batch(cmds...)
  148. }
  149. func (c *completionDialogComponent) View() string {
  150. t := theme.CurrentTheme()
  151. baseStyle := styles.NewStyle().Foreground(t.Text())
  152. maxWidth := 40
  153. completions := c.list.GetItems()
  154. for _, cmd := range completions {
  155. title := cmd.DisplayValue()
  156. width := lipgloss.Width(title)
  157. if width > maxWidth-4 {
  158. maxWidth = width + 4
  159. }
  160. }
  161. c.list.SetMaxWidth(maxWidth)
  162. return baseStyle.
  163. Padding(0, 0).
  164. Background(t.BackgroundElement()).
  165. BorderStyle(lipgloss.ThickBorder()).
  166. BorderLeft(true).
  167. BorderRight(true).
  168. BorderForeground(t.Border()).
  169. BorderBackground(t.Background()).
  170. Width(c.width).
  171. Render(c.list.View())
  172. }
  173. func (c *completionDialogComponent) SetWidth(width int) {
  174. c.width = width
  175. }
  176. func (c *completionDialogComponent) IsEmpty() bool {
  177. return c.list.IsEmpty()
  178. }
  179. func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
  180. value := c.pseudoSearchTextArea.Value()
  181. return tea.Batch(
  182. util.CmdHandler(CompletionSelectedMsg{
  183. SearchString: value,
  184. CompletionValue: item.GetValue(),
  185. ProviderID: c.completionProvider.GetId(),
  186. }),
  187. c.close(),
  188. )
  189. }
  190. func (c *completionDialogComponent) close() tea.Cmd {
  191. c.pseudoSearchTextArea.Reset()
  192. c.pseudoSearchTextArea.Blur()
  193. return util.CmdHandler(CompletionDialogCloseMsg{})
  194. }
  195. func NewCompletionDialogComponent(completionProvider CompletionProvider, trigger string) CompletionDialog {
  196. ti := textarea.New()
  197. li := list.NewListComponent(
  198. []CompletionItemI{},
  199. 7,
  200. completionProvider.GetEmptyMessage(),
  201. false,
  202. )
  203. go func() {
  204. items, err := completionProvider.GetChildEntries("")
  205. if err != nil {
  206. slog.Error("Failed to get completion items", "error", err)
  207. }
  208. li.SetItems(items)
  209. }()
  210. // Initialize the textarea with the trigger character
  211. ti.SetValue(trigger)
  212. return &completionDialogComponent{
  213. query: "",
  214. completionProvider: completionProvider,
  215. pseudoSearchTextArea: ti,
  216. list: li,
  217. trigger: trigger,
  218. }
  219. }