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() string {
147	t := styles.CurrentTheme()
148	listView := c.commandList
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.View(),
154		"",
155		t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
156	)
157	return c.style().Render(content)
158}
159
160func (c *commandDialogCmp) Cursor() *tea.Cursor {
161	if cursor, ok := c.commandList.(util.Cursor); ok {
162		cursor := cursor.Cursor()
163		if cursor != nil {
164			cursor = c.moveCursor(cursor)
165		}
166		return cursor
167	}
168	return nil
169}
170
171func (c *commandDialogCmp) commandTypeRadio() string {
172	t := styles.CurrentTheme()
173	choices := []string{"System", "User"}
174	iconSelected := "◉"
175	iconUnselected := "○"
176	if c.commandType == SystemCommands {
177		return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
178	}
179	return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
180}
181
182func (c *commandDialogCmp) listWidth() int {
183	return defaultWidth - 2 // 4 for padding
184}
185
186func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd {
187	c.commandType = commandType
188
189	var commands []Command
190	if c.commandType == SystemCommands {
191		commands = c.defaultCommands()
192	} else {
193		commands = c.userCommands
194	}
195
196	commandItems := []util.Model{}
197	for _, cmd := range commands {
198		opts := []completions.CompletionOption{}
199		if cmd.Shortcut != "" {
200			opts = append(opts, completions.WithShortcut(cmd.Shortcut))
201		}
202		commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd, opts...))
203	}
204	return c.commandList.SetItems(commandItems)
205}
206
207func (c *commandDialogCmp) listHeight() int {
208	listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
209	return min(listHeigh, c.wHeight/2)
210}
211
212func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
213	row, col := c.Position()
214	offset := row + 3
215	cursor.Y += offset
216	cursor.X = cursor.X + col + 2
217	return cursor
218}
219
220func (c *commandDialogCmp) style() lipgloss.Style {
221	t := styles.CurrentTheme()
222	return t.S().Base.
223		Width(c.width).
224		Border(lipgloss.RoundedBorder()).
225		BorderForeground(t.BorderFocus)
226}
227
228func (c *commandDialogCmp) Position() (int, int) {
229	row := c.wHeight/4 - 2 // just a bit above the center
230	col := c.wWidth / 2
231	col -= c.width / 2
232	return row, col
233}
234
235func (c *commandDialogCmp) defaultCommands() []Command {
236	commands := []Command{
237		{
238			ID:          "init",
239			Title:       "Initialize Project",
240			Description: "Create/Update the CRUSH.md memory file",
241			Handler: func(cmd Command) tea.Cmd {
242				prompt := `Please analyze this codebase and create a CRUSH.md file containing:
243	1. Build/lint/test commands - especially for running a single test
244	2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
245
246	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.
247	If there's already a CRUSH.md, improve it.
248	If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.
249	Add the .crush directory to the .gitignore file if it's not already there.`
250				return util.CmdHandler(chat.SendMsg{
251					Text: prompt,
252				})
253			},
254		},
255	}
256
257	// Only show compact command if there's an active session
258	if c.sessionID != "" {
259		commands = append(commands, Command{
260			ID:          "Summarize",
261			Title:       "Summarize Session",
262			Description: "Summarize the current session and create a new one with the summary",
263			Handler: func(cmd Command) tea.Cmd {
264				return util.CmdHandler(CompactMsg{
265					SessionID: c.sessionID,
266				})
267			},
268		})
269	}
270	// Only show toggle compact mode command if window width is larger than compact breakpoint (90)
271	if c.wWidth > 120 && c.sessionID != "" {
272		commands = append(commands, Command{
273			ID:          "toggle_sidebar",
274			Title:       "Toggle Sidebar",
275			Description: "Toggle between compact and normal layout",
276			Handler: func(cmd Command) tea.Cmd {
277				return util.CmdHandler(ToggleCompactModeMsg{})
278			},
279		})
280	}
281
282	return append(commands, []Command{
283		{
284			ID:          "switch_session",
285			Title:       "Switch Session",
286			Description: "Switch to a different session",
287			Shortcut:    "ctrl+s",
288			Handler: func(cmd Command) tea.Cmd {
289				return util.CmdHandler(SwitchSessionsMsg{})
290			},
291		},
292		{
293			ID:          "switch_model",
294			Title:       "Switch Model",
295			Description: "Switch to a different model",
296			Handler: func(cmd Command) tea.Cmd {
297				return util.CmdHandler(SwitchModelMsg{})
298			},
299		},
300	}...)
301}
302
303func (c *commandDialogCmp) ID() dialogs.DialogID {
304	return CommandsDialogID
305}