commands.go

  1package commands
  2
  3import (
  4	"github.com/charmbracelet/bubbles/v2/key"
  5	tea "github.com/charmbracelet/bubbletea/v2"
  6	"github.com/charmbracelet/lipgloss/v2"
  7
  8	"github.com/opencode-ai/opencode/internal/tui/components/chat"
  9	"github.com/opencode-ai/opencode/internal/tui/components/completions"
 10	"github.com/opencode-ai/opencode/internal/tui/components/core/list"
 11	"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
 12	"github.com/opencode-ai/opencode/internal/tui/styles"
 13	"github.com/opencode-ai/opencode/internal/tui/theme"
 14	"github.com/opencode-ai/opencode/internal/tui/util"
 15)
 16
 17const (
 18	commandsDialogID dialogs.DialogID = "commands"
 19
 20	defaultWidth int = 60
 21)
 22
 23// Command represents a command that can be executed
 24type Command struct {
 25	ID          string
 26	Title       string
 27	Description string
 28	Handler     func(cmd Command) tea.Cmd
 29}
 30
 31// CommandsDialog represents the commands dialog.
 32type CommandsDialog interface {
 33	dialogs.DialogModel
 34}
 35
 36type commandDialogCmp struct {
 37	width   int
 38	wWidth  int // Width of the terminal window
 39	wHeight int // Height of the terminal window
 40
 41	commandList list.ListModel
 42	commands    []Command
 43	keyMap      CommandsDialogKeyMap
 44}
 45
 46func NewCommandDialog() CommandsDialog {
 47	listKeyMap := list.DefaultKeyMap()
 48	keyMap := DefaultCommandsDialogKeyMap()
 49
 50	listKeyMap.Down.SetEnabled(false)
 51	listKeyMap.Up.SetEnabled(false)
 52	listKeyMap.NDown.SetEnabled(false)
 53	listKeyMap.NUp.SetEnabled(false)
 54	listKeyMap.HalfPageDown.SetEnabled(false)
 55	listKeyMap.HalfPageUp.SetEnabled(false)
 56	listKeyMap.Home.SetEnabled(false)
 57	listKeyMap.End.SetEnabled(false)
 58
 59	listKeyMap.DownOneItem = keyMap.Next
 60	listKeyMap.UpOneItem = keyMap.Previous
 61
 62	commandList := list.New(list.WithFilterable(true), list.WithKeyMap(listKeyMap))
 63	return &commandDialogCmp{
 64		commandList: commandList,
 65		width:       defaultWidth,
 66		keyMap:      DefaultCommandsDialogKeyMap(),
 67	}
 68}
 69
 70func (c *commandDialogCmp) Init() tea.Cmd {
 71	commands, err := LoadCustomCommands()
 72	if err != nil {
 73		return util.ReportError(err)
 74	}
 75	c.commands = commands
 76
 77	commandItems := []util.Model{}
 78	if len(commands) > 0 {
 79		commandItems = append(commandItems, NewItemSection("Custom Commands"))
 80		for _, cmd := range commands {
 81			commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd))
 82		}
 83	}
 84
 85	commandItems = append(commandItems, NewItemSection("Default"))
 86
 87	for _, cmd := range c.defaultCommands() {
 88		c.commands = append(c.commands, cmd)
 89		commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd))
 90	}
 91
 92	c.commandList.SetItems(commandItems)
 93	return c.commandList.Init()
 94}
 95
 96func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 97	switch msg := msg.(type) {
 98	case tea.WindowSizeMsg:
 99		c.wWidth = msg.Width
100		c.wHeight = msg.Height
101		return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
102	case tea.KeyPressMsg:
103		switch {
104		case key.Matches(msg, c.keyMap.Select):
105			selectedItemInx := c.commandList.SelectedIndex()
106			if selectedItemInx == list.NoSelection {
107				return c, nil // No item selected, do nothing
108			}
109			items := c.commandList.Items()
110			selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(Command)
111			return c, tea.Sequence(
112				util.CmdHandler(dialogs.CloseDialogMsg{}),
113				selectedItem.Handler(selectedItem),
114			)
115		default:
116			u, cmd := c.commandList.Update(msg)
117			c.commandList = u.(list.ListModel)
118			return c, cmd
119		}
120	}
121	return c, nil
122}
123
124func (c *commandDialogCmp) View() tea.View {
125	listView := c.commandList.View()
126	v := tea.NewView(c.style().Render(listView.String()))
127	if listView.Cursor() != nil {
128		c := c.moveCursor(listView.Cursor())
129		v.SetCursor(c)
130	}
131	return v
132}
133
134func (c *commandDialogCmp) listWidth() int {
135	return defaultWidth - 4 // 4 for padding
136}
137
138func (c *commandDialogCmp) listHeight() int {
139	listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
140	return min(listHeigh, c.wHeight/2)
141}
142
143func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
144	offset := 10 + 1
145	cursor.Y += offset
146	_, col := c.Position()
147	cursor.X = cursor.X + col + 2
148	return cursor
149}
150
151func (c *commandDialogCmp) style() lipgloss.Style {
152	t := theme.CurrentTheme()
153	return styles.BaseStyle().
154		Width(c.width).
155		Padding(0, 1, 1, 1).
156		Border(lipgloss.RoundedBorder()).
157		BorderBackground(t.Background()).
158		BorderForeground(t.TextMuted())
159}
160
161func (q *commandDialogCmp) Position() (int, int) {
162	row := 10
163	col := q.wWidth / 2
164	col -= q.width / 2
165	return row, col
166}
167
168func (c *commandDialogCmp) defaultCommands() []Command {
169	return []Command{
170		{
171			ID:          "init",
172			Title:       "Initialize Project",
173			Description: "Create/Update the OpenCode.md memory file",
174			Handler: func(cmd Command) tea.Cmd {
175				prompt := `Please analyze this codebase and create a OpenCode.md file containing:
176	1. Build/lint/test commands - especially for running a single test
177	2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
178
179	The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
180	If there's already a opencode.md, improve it.
181	If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
182				return tea.Batch(
183					util.CmdHandler(chat.SendMsg{
184						Text: prompt,
185					}),
186				)
187			},
188		},
189		{
190			ID:          "compact",
191			Title:       "Compact Session",
192			Description: "Summarize the current session and create a new one with the summary",
193			Handler: func(cmd Command) tea.Cmd {
194				return func() tea.Msg {
195					// TODO: implement compact message
196					return ""
197				}
198			},
199		},
200	}
201}
202
203func (c *commandDialogCmp) ID() dialogs.DialogID {
204	return commandsDialogID
205}