commands.go

  1package commands
  2
  3import (
  4	"github.com/charmbracelet/bubbles/v2/help"
  5	"github.com/charmbracelet/bubbles/v2/key"
  6	tea "github.com/charmbracelet/bubbletea/v2"
  7	"github.com/charmbracelet/lipgloss/v2"
  8
  9	"github.com/opencode-ai/opencode/internal/tui/components/chat"
 10	"github.com/opencode-ai/opencode/internal/tui/components/completions"
 11	"github.com/opencode-ai/opencode/internal/tui/components/core"
 12	"github.com/opencode-ai/opencode/internal/tui/components/core/list"
 13	"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
 14	"github.com/opencode-ai/opencode/internal/tui/styles"
 15	"github.com/opencode-ai/opencode/internal/tui/util"
 16)
 17
 18const (
 19	commandsDialogID dialogs.DialogID = "commands"
 20
 21	defaultWidth int = 70
 22)
 23
 24const (
 25	SystemCommands int = iota
 26	UserCommands
 27)
 28
 29// Command represents a command that can be executed
 30type Command struct {
 31	ID          string
 32	Title       string
 33	Description string
 34	Handler     func(cmd Command) tea.Cmd
 35}
 36
 37// CommandsDialog represents the commands dialog.
 38type CommandsDialog interface {
 39	dialogs.DialogModel
 40}
 41
 42type commandDialogCmp struct {
 43	width   int
 44	wWidth  int // Width of the terminal window
 45	wHeight int // Height of the terminal window
 46
 47	commandList  list.ListModel
 48	keyMap       CommandsDialogKeyMap
 49	help         help.Model
 50	commandType  int       // SystemCommands or UserCommands
 51	userCommands []Command // User-defined commands
 52}
 53
 54type (
 55	SwitchSessionsMsg struct{}
 56	SwitchModelMsg    struct{}
 57)
 58
 59func NewCommandDialog() CommandsDialog {
 60	listKeyMap := list.DefaultKeyMap()
 61	keyMap := DefaultCommandsDialogKeyMap()
 62
 63	listKeyMap.Down.SetEnabled(false)
 64	listKeyMap.Up.SetEnabled(false)
 65	listKeyMap.NDown.SetEnabled(false)
 66	listKeyMap.NUp.SetEnabled(false)
 67	listKeyMap.HalfPageDown.SetEnabled(false)
 68	listKeyMap.HalfPageUp.SetEnabled(false)
 69	listKeyMap.Home.SetEnabled(false)
 70	listKeyMap.End.SetEnabled(false)
 71
 72	listKeyMap.DownOneItem = keyMap.Next
 73	listKeyMap.UpOneItem = keyMap.Previous
 74
 75	t := styles.CurrentTheme()
 76	commandList := list.New(
 77		list.WithFilterable(true),
 78		list.WithKeyMap(listKeyMap),
 79		list.WithWrapNavigation(true),
 80	)
 81	help := help.New()
 82	help.Styles = t.S().Help
 83	return &commandDialogCmp{
 84		commandList: commandList,
 85		width:       defaultWidth,
 86		keyMap:      DefaultCommandsDialogKeyMap(),
 87		help:        help,
 88		commandType: SystemCommands,
 89	}
 90}
 91
 92func (c *commandDialogCmp) Init() tea.Cmd {
 93	commands, err := LoadCustomCommands()
 94	if err != nil {
 95		return util.ReportError(err)
 96	}
 97
 98	c.userCommands = commands
 99	c.SetCommandType(c.commandType)
100	return c.commandList.Init()
101}
102
103func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
104	switch msg := msg.(type) {
105	case tea.WindowSizeMsg:
106		c.wWidth = msg.Width
107		c.wHeight = msg.Height
108		return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
109	case tea.KeyPressMsg:
110		switch {
111		case key.Matches(msg, c.keyMap.Select):
112			selectedItemInx := c.commandList.SelectedIndex()
113			if selectedItemInx == list.NoSelection {
114				return c, nil // No item selected, do nothing
115			}
116			items := c.commandList.Items()
117			selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(Command)
118			return c, tea.Sequence(
119				util.CmdHandler(dialogs.CloseDialogMsg{}),
120				selectedItem.Handler(selectedItem),
121			)
122		case key.Matches(msg, c.keyMap.Tab):
123			// Toggle command type between System and User commands
124			if c.commandType == SystemCommands {
125				return c, c.SetCommandType(UserCommands)
126			} else {
127				return c, c.SetCommandType(SystemCommands)
128			}
129		default:
130			u, cmd := c.commandList.Update(msg)
131			c.commandList = u.(list.ListModel)
132			return c, cmd
133		}
134	}
135	return c, nil
136}
137
138func (c *commandDialogCmp) View() tea.View {
139	t := styles.CurrentTheme()
140	listView := c.commandList.View()
141	radio := c.commandTypeRadio()
142	content := lipgloss.JoinVertical(
143		lipgloss.Left,
144		t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5)+" "+radio),
145		listView.String(),
146		"",
147		t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
148	)
149	v := tea.NewView(c.style().Render(content))
150	if listView.Cursor() != nil {
151		c := c.moveCursor(listView.Cursor())
152		v.SetCursor(c)
153	}
154	return v
155}
156
157func (c *commandDialogCmp) commandTypeRadio() string {
158	t := styles.CurrentTheme()
159	choices := []string{"System", "User"}
160	iconSelected := "◉"
161	iconUnselected := "○"
162	if c.commandType == SystemCommands {
163		return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
164	}
165	return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
166}
167
168func (c *commandDialogCmp) listWidth() int {
169	return defaultWidth - 2 // 4 for padding
170}
171
172func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd {
173	c.commandType = commandType
174
175	var commands []Command
176	if c.commandType == SystemCommands {
177		commands = c.defaultCommands()
178	} else {
179		commands = c.userCommands
180	}
181
182	commandItems := []util.Model{}
183	for _, cmd := range commands {
184		commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd))
185	}
186	return c.commandList.SetItems(commandItems)
187}
188
189func (c *commandDialogCmp) listHeight() int {
190	listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
191	return min(listHeigh, c.wHeight/2)
192}
193
194func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
195	row, col := c.Position()
196	offset := row + 3
197	cursor.Y += offset
198	cursor.X = cursor.X + col + 2
199	return cursor
200}
201
202func (c *commandDialogCmp) style() lipgloss.Style {
203	t := styles.CurrentTheme()
204	return t.S().Base.
205		Width(c.width).
206		Border(lipgloss.RoundedBorder()).
207		BorderForeground(t.BorderFocus)
208}
209
210func (c *commandDialogCmp) Position() (int, int) {
211	row := c.wHeight/4 - 2 // just a bit above the center
212	col := c.wWidth / 2
213	col -= c.width / 2
214	return row, col
215}
216
217func (c *commandDialogCmp) defaultCommands() []Command {
218	return []Command{
219		{
220			ID:          "init",
221			Title:       "Initialize Project",
222			Description: "Create/Update the OpenCode.md memory file",
223			Handler: func(cmd Command) tea.Cmd {
224				prompt := `Please analyze this codebase and create a OpenCode.md file containing:
225	1. Build/lint/test commands - especially for running a single test
226	2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
227
228	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.
229	If there's already a opencode.md, improve it.
230	If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
231				return tea.Batch(
232					util.CmdHandler(chat.SendMsg{
233						Text: prompt,
234					}),
235				)
236			},
237		},
238		{
239			ID:          "compact",
240			Title:       "Compact Session",
241			Description: "Summarize the current session and create a new one with the summary",
242			Handler: func(cmd Command) tea.Cmd {
243				return func() tea.Msg {
244					// TODO: implement compact message
245					return ""
246				}
247			},
248		},
249		{
250			ID:          "switch_session",
251			Title:       "Switch Session",
252			Description: "Switch to a different session",
253			Handler: func(cmd Command) tea.Cmd {
254				return func() tea.Msg {
255					return SwitchSessionsMsg{}
256				}
257			},
258		},
259		{
260			ID:          "switch_model",
261			Title:       "Switch Model",
262			Description: "Switch to a different model",
263			Handler: func(cmd Command) tea.Cmd {
264				return func() tea.Msg {
265					return SwitchModelMsg{}
266				}
267			},
268		},
269	}
270}
271
272func (c *commandDialogCmp) ID() dialogs.DialogID {
273	return commandsDialogID
274}