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