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