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