commands.go

  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		styles.BaseStyle.Width(maxWidth).Padding(0, 1).Foreground(styles.ForgroundDim).Render("↑/k: up  ↓/j: down  enter: select  esc: cancel"),
194	)
195
196	return styles.BaseStyle.Padding(1, 2).
197		Border(lipgloss.RoundedBorder()).
198		BorderBackground(styles.Background).
199		BorderForeground(styles.ForgroundDim).
200		Width(lipgloss.Width(content) + 4).
201		Render(content)
202}
203
204func (c *commandDialogCmp) BindingKeys() []key.Binding {
205	return layout.KeyMapToSlice(commandKeys)
206}
207
208func (c *commandDialogCmp) SetCommands(commands []Command) {
209	c.commands = commands
210
211	// If we have a selected command ID, find its index
212	if c.selectedCommandID != "" {
213		for i, cmd := range commands {
214			if cmd.ID == c.selectedCommandID {
215				c.selectedIdx = i
216				return
217			}
218		}
219	}
220
221	// Default to first command if selected not found
222	c.selectedIdx = 0
223}
224
225func (c *commandDialogCmp) SetSelectedCommand(commandID string) {
226	c.selectedCommandID = commandID
227
228	// Update the selected index if commands are already loaded
229	if len(c.commands) > 0 {
230		for i, cmd := range c.commands {
231			if cmd.ID == commandID {
232				c.selectedIdx = i
233				return
234			}
235		}
236	}
237}
238
239// NewCommandDialogCmp creates a new command selection dialog
240func NewCommandDialogCmp() CommandDialog {
241	return &commandDialogCmp{
242		commands:          []Command{},
243		selectedIdx:       0,
244		selectedCommandID: "",
245	}
246}
247