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	ToggleCompactModeMsg struct{}
 60	CompactMsg           struct {
 61		SessionID string
 62	}
 63)
 64
 65func NewCommandDialog(sessionID string) CommandsDialog {
 66	listKeyMap := list.DefaultKeyMap()
 67	keyMap := DefaultCommandsDialogKeyMap()
 68
 69	listKeyMap.Down.SetEnabled(false)
 70	listKeyMap.Up.SetEnabled(false)
 71	listKeyMap.HalfPageDown.SetEnabled(false)
 72	listKeyMap.HalfPageUp.SetEnabled(false)
 73	listKeyMap.Home.SetEnabled(false)
 74	listKeyMap.End.SetEnabled(false)
 75
 76	listKeyMap.DownOneItem = keyMap.Next
 77	listKeyMap.UpOneItem = keyMap.Previous
 78
 79	t := styles.CurrentTheme()
 80	commandList := list.New(
 81		list.WithFilterable(true),
 82		list.WithKeyMap(listKeyMap),
 83		list.WithWrapNavigation(true),
 84	)
 85	help := help.New()
 86	help.Styles = t.S().Help
 87	return &commandDialogCmp{
 88		commandList: commandList,
 89		width:       defaultWidth,
 90		keyMap:      DefaultCommandsDialogKeyMap(),
 91		help:        help,
 92		commandType: SystemCommands,
 93		sessionID:   sessionID,
 94	}
 95}
 96
 97func (c *commandDialogCmp) Init() tea.Cmd {
 98	commands, err := LoadCustomCommands()
 99	if err != nil {
100		return util.ReportError(err)
101	}
102
103	c.userCommands = commands
104	c.SetCommandType(c.commandType)
105	return c.commandList.Init()
106}
107
108func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
109	switch msg := msg.(type) {
110	case tea.WindowSizeMsg:
111		c.wWidth = msg.Width
112		c.wHeight = msg.Height
113		c.SetCommandType(c.commandType)
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	Add the .crush directory to the .gitignore file if it's not already there.`
244				return util.CmdHandler(chat.SendMsg{
245					Text: prompt,
246				})
247			},
248		},
249	}
250
251	// Only show compact command if there's an active session
252	if c.sessionID != "" {
253		commands = append(commands, Command{
254			ID:          "Summarize",
255			Title:       "Summarize Session",
256			Description: "Summarize the current session and create a new one with the summary",
257			Handler: func(cmd Command) tea.Cmd {
258				return util.CmdHandler(CompactMsg{
259					SessionID: c.sessionID,
260				})
261			},
262		})
263	}
264	// Only show toggle compact mode command if window width is larger than compact breakpoint (90)
265	if c.wWidth > 120 && c.sessionID != "" {
266		commands = append(commands, Command{
267			ID:          "toggle_sidebar",
268			Title:       "Toggle Sidebar",
269			Description: "Toggle between compact and normal layout",
270			Handler: func(cmd Command) tea.Cmd {
271				return util.CmdHandler(ToggleCompactModeMsg{})
272			},
273		})
274	}
275
276	return append(commands, []Command{
277		{
278			ID:          "switch_session",
279			Title:       "Switch Session",
280			Description: "Switch to a different session",
281			Shortcut:    "ctrl+s",
282			Handler: func(cmd Command) tea.Cmd {
283				return util.CmdHandler(SwitchSessionsMsg{})
284			},
285		},
286		{
287			ID:          "switch_model",
288			Title:       "Switch Model",
289			Description: "Switch to a different model",
290			Handler: func(cmd Command) tea.Cmd {
291				return util.CmdHandler(SwitchModelMsg{})
292			},
293		},
294	}...)
295}
296
297func (c *commandDialogCmp) ID() dialogs.DialogID {
298	return CommandsDialogID
299}