complete.go 5.8 KB

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