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