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