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