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/llm/prompt"
 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}
 56
 57type (
 58	SwitchSessionsMsg    struct{}
 59	SwitchModelMsg       struct{}
 60	ToggleCompactModeMsg struct{}
 61	CompactMsg           struct {
 62		SessionID string
 63	}
 64)
 65
 66func NewCommandDialog(sessionID string) CommandsDialog {
 67	listKeyMap := list.DefaultKeyMap()
 68	keyMap := DefaultCommandsDialogKeyMap()
 69
 70	listKeyMap.Down.SetEnabled(false)
 71	listKeyMap.Up.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		c.SetCommandType(c.commandType)
115		return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
116	case tea.KeyPressMsg:
117		switch {
118		case key.Matches(msg, c.keyMap.Select):
119			selectedItemInx := c.commandList.SelectedIndex()
120			if selectedItemInx == list.NoSelection {
121				return c, nil // No item selected, do nothing
122			}
123			items := c.commandList.Items()
124			selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(Command)
125			return c, tea.Sequence(
126				util.CmdHandler(dialogs.CloseDialogMsg{}),
127				selectedItem.Handler(selectedItem),
128			)
129		case key.Matches(msg, c.keyMap.Tab):
130			// Toggle command type between System and User commands
131			if c.commandType == SystemCommands {
132				return c, c.SetCommandType(UserCommands)
133			} else {
134				return c, c.SetCommandType(SystemCommands)
135			}
136		case key.Matches(msg, c.keyMap.Close):
137			return c, util.CmdHandler(dialogs.CloseDialogMsg{})
138		default:
139			u, cmd := c.commandList.Update(msg)
140			c.commandList = u.(list.ListModel)
141			return c, cmd
142		}
143	}
144	return c, nil
145}
146
147func (c *commandDialogCmp) View() string {
148	t := styles.CurrentTheme()
149	listView := c.commandList
150	radio := c.commandTypeRadio()
151	content := lipgloss.JoinVertical(
152		lipgloss.Left,
153		t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5)+" "+radio),
154		listView.View(),
155		"",
156		t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
157	)
158	return c.style().Render(content)
159}
160
161func (c *commandDialogCmp) Cursor() *tea.Cursor {
162	if cursor, ok := c.commandList.(util.Cursor); ok {
163		cursor := cursor.Cursor()
164		if cursor != nil {
165			cursor = c.moveCursor(cursor)
166		}
167		return cursor
168	}
169	return nil
170}
171
172func (c *commandDialogCmp) commandTypeRadio() string {
173	t := styles.CurrentTheme()
174	choices := []string{"System", "User"}
175	iconSelected := "◉"
176	iconUnselected := "○"
177	if c.commandType == SystemCommands {
178		return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
179	}
180	return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
181}
182
183func (c *commandDialogCmp) listWidth() int {
184	return defaultWidth - 2 // 4 for padding
185}
186
187func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd {
188	c.commandType = commandType
189
190	var commands []Command
191	if c.commandType == SystemCommands {
192		commands = c.defaultCommands()
193	} else {
194		commands = c.userCommands
195	}
196
197	commandItems := []util.Model{}
198	for _, cmd := range commands {
199		opts := []completions.CompletionOption{}
200		if cmd.Shortcut != "" {
201			opts = append(opts, completions.WithShortcut(cmd.Shortcut))
202		}
203		commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd, opts...))
204	}
205	return c.commandList.SetItems(commandItems)
206}
207
208func (c *commandDialogCmp) listHeight() int {
209	listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
210	return min(listHeigh, c.wHeight/2)
211}
212
213func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
214	row, col := c.Position()
215	offset := row + 3
216	cursor.Y += offset
217	cursor.X = cursor.X + col + 2
218	return cursor
219}
220
221func (c *commandDialogCmp) style() lipgloss.Style {
222	t := styles.CurrentTheme()
223	return t.S().Base.
224		Width(c.width).
225		Border(lipgloss.RoundedBorder()).
226		BorderForeground(t.BorderFocus)
227}
228
229func (c *commandDialogCmp) Position() (int, int) {
230	row := c.wHeight/4 - 2 // just a bit above the center
231	col := c.wWidth / 2
232	col -= c.width / 2
233	return row, col
234}
235
236func (c *commandDialogCmp) defaultCommands() []Command {
237	commands := []Command{
238		{
239			ID:          "init",
240			Title:       "Initialize Project",
241			Description: "Create/Update the CRUSH.md memory file",
242			Handler: func(cmd Command) tea.Cmd {
243				return util.CmdHandler(chat.SendMsg{
244					Text: prompt.Initialize(),
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:          "Summarize",
254			Title:       "Summarize 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	// Only show toggle compact mode command if window width is larger than compact breakpoint (90)
264	if c.wWidth > 120 && c.sessionID != "" {
265		commands = append(commands, Command{
266			ID:          "toggle_sidebar",
267			Title:       "Toggle Sidebar",
268			Description: "Toggle between compact and normal layout",
269			Handler: func(cmd Command) tea.Cmd {
270				return util.CmdHandler(ToggleCompactModeMsg{})
271			},
272		})
273	}
274
275	return append(commands, []Command{
276		{
277			ID:          "switch_session",
278			Title:       "Switch Session",
279			Description: "Switch to a different session",
280			Shortcut:    "ctrl+s",
281			Handler: func(cmd Command) tea.Cmd {
282				return util.CmdHandler(SwitchSessionsMsg{})
283			},
284		},
285		{
286			ID:          "switch_model",
287			Title:       "Switch Model",
288			Description: "Switch to a different model",
289			Handler: func(cmd Command) tea.Cmd {
290				return util.CmdHandler(SwitchModelMsg{})
291			},
292		},
293	}...)
294}
295
296func (c *commandDialogCmp) ID() dialogs.DialogID {
297	return CommandsDialogID
298}