commands.go 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. package dialog
  2. import (
  3. "github.com/charmbracelet/bubbles/key"
  4. tea "github.com/charmbracelet/bubbletea"
  5. "github.com/charmbracelet/lipgloss"
  6. "github.com/opencode-ai/opencode/internal/tui/layout"
  7. "github.com/opencode-ai/opencode/internal/tui/styles"
  8. "github.com/opencode-ai/opencode/internal/tui/theme"
  9. "github.com/opencode-ai/opencode/internal/tui/util"
  10. )
  11. // Command represents a command that can be executed
  12. type Command struct {
  13. ID string
  14. Title string
  15. Description string
  16. Handler func(cmd Command) tea.Cmd
  17. }
  18. // CommandSelectedMsg is sent when a command is selected
  19. type CommandSelectedMsg struct {
  20. Command Command
  21. }
  22. // CloseCommandDialogMsg is sent when the command dialog is closed
  23. type CloseCommandDialogMsg struct{}
  24. // CommandDialog interface for the command selection dialog
  25. type CommandDialog interface {
  26. tea.Model
  27. layout.Bindings
  28. SetCommands(commands []Command)
  29. SetSelectedCommand(commandID string)
  30. }
  31. type commandDialogCmp struct {
  32. commands []Command
  33. selectedIdx int
  34. width int
  35. height int
  36. selectedCommandID string
  37. }
  38. type commandKeyMap struct {
  39. Up key.Binding
  40. Down key.Binding
  41. Enter key.Binding
  42. Escape key.Binding
  43. J key.Binding
  44. K key.Binding
  45. }
  46. var commandKeys = commandKeyMap{
  47. Up: key.NewBinding(
  48. key.WithKeys("up"),
  49. key.WithHelp("↑", "previous command"),
  50. ),
  51. Down: key.NewBinding(
  52. key.WithKeys("down"),
  53. key.WithHelp("↓", "next command"),
  54. ),
  55. Enter: key.NewBinding(
  56. key.WithKeys("enter"),
  57. key.WithHelp("enter", "select command"),
  58. ),
  59. Escape: key.NewBinding(
  60. key.WithKeys("esc"),
  61. key.WithHelp("esc", "close"),
  62. ),
  63. J: key.NewBinding(
  64. key.WithKeys("j"),
  65. key.WithHelp("j", "next command"),
  66. ),
  67. K: key.NewBinding(
  68. key.WithKeys("k"),
  69. key.WithHelp("k", "previous command"),
  70. ),
  71. }
  72. func (c *commandDialogCmp) Init() tea.Cmd {
  73. return nil
  74. }
  75. func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  76. switch msg := msg.(type) {
  77. case tea.KeyMsg:
  78. switch {
  79. case key.Matches(msg, commandKeys.Up) || key.Matches(msg, commandKeys.K):
  80. if c.selectedIdx > 0 {
  81. c.selectedIdx--
  82. }
  83. return c, nil
  84. case key.Matches(msg, commandKeys.Down) || key.Matches(msg, commandKeys.J):
  85. if c.selectedIdx < len(c.commands)-1 {
  86. c.selectedIdx++
  87. }
  88. return c, nil
  89. case key.Matches(msg, commandKeys.Enter):
  90. if len(c.commands) > 0 {
  91. return c, util.CmdHandler(CommandSelectedMsg{
  92. Command: c.commands[c.selectedIdx],
  93. })
  94. }
  95. case key.Matches(msg, commandKeys.Escape):
  96. return c, util.CmdHandler(CloseCommandDialogMsg{})
  97. }
  98. case tea.WindowSizeMsg:
  99. c.width = msg.Width
  100. c.height = msg.Height
  101. }
  102. return c, nil
  103. }
  104. func (c *commandDialogCmp) View() string {
  105. t := theme.CurrentTheme()
  106. baseStyle := styles.BaseStyle()
  107. if len(c.commands) == 0 {
  108. return baseStyle.Padding(1, 2).
  109. Border(lipgloss.RoundedBorder()).
  110. BorderBackground(t.Background()).
  111. BorderForeground(t.TextMuted()).
  112. Width(40).
  113. Render("No commands available")
  114. }
  115. // Calculate max width needed for command titles
  116. maxWidth := 40 // Minimum width
  117. for _, cmd := range c.commands {
  118. if len(cmd.Title) > maxWidth-4 { // Account for padding
  119. maxWidth = len(cmd.Title) + 4
  120. }
  121. if len(cmd.Description) > maxWidth-4 {
  122. maxWidth = len(cmd.Description) + 4
  123. }
  124. }
  125. // Limit height to avoid taking up too much screen space
  126. maxVisibleCommands := min(10, len(c.commands))
  127. // Build the command list
  128. commandItems := make([]string, 0, maxVisibleCommands)
  129. startIdx := 0
  130. // If we have more commands than can be displayed, adjust the start index
  131. if len(c.commands) > maxVisibleCommands {
  132. // Center the selected item when possible
  133. halfVisible := maxVisibleCommands / 2
  134. if c.selectedIdx >= halfVisible && c.selectedIdx < len(c.commands)-halfVisible {
  135. startIdx = c.selectedIdx - halfVisible
  136. } else if c.selectedIdx >= len(c.commands)-halfVisible {
  137. startIdx = len(c.commands) - maxVisibleCommands
  138. }
  139. }
  140. endIdx := min(startIdx+maxVisibleCommands, len(c.commands))
  141. for i := startIdx; i < endIdx; i++ {
  142. cmd := c.commands[i]
  143. itemStyle := baseStyle.Width(maxWidth)
  144. descStyle := baseStyle.Width(maxWidth).Foreground(t.TextMuted())
  145. if i == c.selectedIdx {
  146. itemStyle = itemStyle.
  147. Background(t.Primary()).
  148. Foreground(t.Background()).
  149. Bold(true)
  150. descStyle = descStyle.
  151. Background(t.Primary()).
  152. Foreground(t.Background())
  153. }
  154. title := itemStyle.Padding(0, 1).Render(cmd.Title)
  155. description := ""
  156. if cmd.Description != "" {
  157. description = descStyle.Padding(0, 1).Render(cmd.Description)
  158. commandItems = append(commandItems, lipgloss.JoinVertical(lipgloss.Left, title, description))
  159. } else {
  160. commandItems = append(commandItems, title)
  161. }
  162. }
  163. title := baseStyle.
  164. Foreground(t.Primary()).
  165. Bold(true).
  166. Width(maxWidth).
  167. Padding(0, 1).
  168. Render("Commands")
  169. content := lipgloss.JoinVertical(
  170. lipgloss.Left,
  171. title,
  172. baseStyle.Width(maxWidth).Render(""),
  173. baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, commandItems...)),
  174. baseStyle.Width(maxWidth).Render(""),
  175. )
  176. return baseStyle.Padding(1, 2).
  177. Border(lipgloss.RoundedBorder()).
  178. BorderBackground(t.Background()).
  179. BorderForeground(t.TextMuted()).
  180. Width(lipgloss.Width(content) + 4).
  181. Render(content)
  182. }
  183. func (c *commandDialogCmp) BindingKeys() []key.Binding {
  184. return layout.KeyMapToSlice(commandKeys)
  185. }
  186. func (c *commandDialogCmp) SetCommands(commands []Command) {
  187. c.commands = commands
  188. // If we have a selected command ID, find its index
  189. if c.selectedCommandID != "" {
  190. for i, cmd := range commands {
  191. if cmd.ID == c.selectedCommandID {
  192. c.selectedIdx = i
  193. return
  194. }
  195. }
  196. }
  197. // Default to first command if selected not found
  198. c.selectedIdx = 0
  199. }
  200. func (c *commandDialogCmp) SetSelectedCommand(commandID string) {
  201. c.selectedCommandID = commandID
  202. // Update the selected index if commands are already loaded
  203. if len(c.commands) > 0 {
  204. for i, cmd := range c.commands {
  205. if cmd.ID == commandID {
  206. c.selectedIdx = i
  207. return
  208. }
  209. }
  210. }
  211. }
  212. // NewCommandDialogCmp creates a new command selection dialog
  213. func NewCommandDialogCmp() CommandDialog {
  214. return &commandDialogCmp{
  215. commands: []Command{},
  216. selectedIdx: 0,
  217. selectedCommandID: "",
  218. }
  219. }