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/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}