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/catwalk/pkg/catwalk"
  8	"github.com/charmbracelet/lipgloss/v2"
  9
 10	"github.com/charmbracelet/crush/internal/config"
 11	"github.com/charmbracelet/crush/internal/llm/prompt"
 12	"github.com/charmbracelet/crush/internal/tui/components/chat"
 13	"github.com/charmbracelet/crush/internal/tui/components/core"
 14	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 15	"github.com/charmbracelet/crush/internal/tui/exp/list"
 16	"github.com/charmbracelet/crush/internal/tui/styles"
 17	"github.com/charmbracelet/crush/internal/tui/util"
 18)
 19
 20const (
 21	CommandsDialogID dialogs.DialogID = "commands"
 22
 23	defaultWidth int = 70
 24)
 25
 26const (
 27	SystemCommands int = iota
 28	UserCommands
 29)
 30
 31type listModel = list.FilterableList[list.CompletionItem[Command]]
 32
 33// Command represents a command that can be executed
 34type Command struct {
 35	ID          string
 36	Title       string
 37	Description string
 38	Shortcut    string // Optional shortcut for the command
 39	Handler     func(cmd Command) tea.Cmd
 40}
 41
 42// CommandsDialog represents the commands dialog.
 43type CommandsDialog interface {
 44	dialogs.DialogModel
 45}
 46
 47type commandDialogCmp struct {
 48	width   int
 49	wWidth  int // Width of the terminal window
 50	wHeight int // Height of the terminal window
 51
 52	commandList  listModel
 53	keyMap       CommandsDialogKeyMap
 54	help         help.Model
 55	commandType  int       // SystemCommands or UserCommands
 56	userCommands []Command // User-defined commands
 57	sessionID    string    // Current session ID
 58}
 59
 60type (
 61	SwitchSessionsMsg    struct{}
 62	NewSessionsMsg       struct{}
 63	SwitchModelMsg       struct{}
 64	QuitMsg              struct{}
 65	OpenFilePickerMsg    struct{}
 66	ToggleHelpMsg        struct{}
 67	ToggleCompactModeMsg struct{}
 68	ToggleThinkingMsg    struct{}
 69	CompactMsg           struct {
 70		SessionID string
 71	}
 72)
 73
 74func NewCommandDialog(sessionID string) CommandsDialog {
 75	keyMap := DefaultCommandsDialogKeyMap()
 76	listKeyMap := list.DefaultKeyMap()
 77	listKeyMap.Down.SetEnabled(false)
 78	listKeyMap.Up.SetEnabled(false)
 79	listKeyMap.DownOneItem = keyMap.Next
 80	listKeyMap.UpOneItem = keyMap.Previous
 81
 82	t := styles.CurrentTheme()
 83	inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
 84	commandList := list.NewFilterableList(
 85		[]list.CompletionItem[Command]{},
 86		list.WithFilterInputStyle(inputStyle),
 87		list.WithFilterListOptions(
 88			list.WithKeyMap(listKeyMap),
 89			list.WithWrapNavigation(),
 90			list.WithResizeByList(),
 91		),
 92	)
 93	help := help.New()
 94	help.Styles = t.S().Help
 95	return &commandDialogCmp{
 96		commandList: commandList,
 97		width:       defaultWidth,
 98		keyMap:      DefaultCommandsDialogKeyMap(),
 99		help:        help,
100		commandType: SystemCommands,
101		sessionID:   sessionID,
102	}
103}
104
105func (c *commandDialogCmp) Init() tea.Cmd {
106	commands, err := LoadCustomCommands()
107	if err != nil {
108		return util.ReportError(err)
109	}
110	c.userCommands = commands
111	return c.SetCommandType(c.commandType)
112}
113
114func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
115	switch msg := msg.(type) {
116	case tea.WindowSizeMsg:
117		c.wWidth = msg.Width
118		c.wHeight = msg.Height
119		return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
120	case tea.KeyPressMsg:
121		switch {
122		case key.Matches(msg, c.keyMap.Select):
123			selectedItem := c.commandList.SelectedItem()
124			if selectedItem == nil {
125				return c, nil // No item selected, do nothing
126			}
127			command := (*selectedItem).Value()
128			return c, tea.Sequence(
129				util.CmdHandler(dialogs.CloseDialogMsg{}),
130				command.Handler(command),
131			)
132		case key.Matches(msg, c.keyMap.Tab):
133			if len(c.userCommands) == 0 {
134				return c, nil
135			}
136			// Toggle command type between System and User commands
137			if c.commandType == SystemCommands {
138				return c, c.SetCommandType(UserCommands)
139			} else {
140				return c, c.SetCommandType(SystemCommands)
141			}
142		case key.Matches(msg, c.keyMap.Close):
143			return c, util.CmdHandler(dialogs.CloseDialogMsg{})
144		default:
145			u, cmd := c.commandList.Update(msg)
146			c.commandList = u.(listModel)
147			return c, cmd
148		}
149	}
150	return c, nil
151}
152
153func (c *commandDialogCmp) View() string {
154	t := styles.CurrentTheme()
155	listView := c.commandList
156	radio := c.commandTypeRadio()
157
158	header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5) + " " + radio)
159	if len(c.userCommands) == 0 {
160		header = t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-4))
161	}
162	content := lipgloss.JoinVertical(
163		lipgloss.Left,
164		header,
165		listView.View(),
166		"",
167		t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
168	)
169	return c.style().Render(content)
170}
171
172func (c *commandDialogCmp) Cursor() *tea.Cursor {
173	if cursor, ok := c.commandList.(util.Cursor); ok {
174		cursor := cursor.Cursor()
175		if cursor != nil {
176			cursor = c.moveCursor(cursor)
177		}
178		return cursor
179	}
180	return nil
181}
182
183func (c *commandDialogCmp) commandTypeRadio() string {
184	t := styles.CurrentTheme()
185	choices := []string{"System", "User"}
186	iconSelected := "◉"
187	iconUnselected := "○"
188	if c.commandType == SystemCommands {
189		return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
190	}
191	return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
192}
193
194func (c *commandDialogCmp) listWidth() int {
195	return defaultWidth - 2 // 4 for padding
196}
197
198func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd {
199	c.commandType = commandType
200
201	var commands []Command
202	if c.commandType == SystemCommands {
203		commands = c.defaultCommands()
204	} else {
205		commands = c.userCommands
206	}
207
208	commandItems := []list.CompletionItem[Command]{}
209	for _, cmd := range commands {
210		opts := []list.CompletionItemOption{
211			list.WithCompletionID(cmd.ID),
212		}
213		if cmd.Shortcut != "" {
214			opts = append(
215				opts,
216				list.WithCompletionShortcut(cmd.Shortcut),
217			)
218		}
219		commandItems = append(commandItems, list.NewCompletionItem(cmd.Title, cmd, opts...))
220	}
221	return c.commandList.SetItems(commandItems)
222}
223
224func (c *commandDialogCmp) listHeight() int {
225	listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
226	return min(listHeigh, c.wHeight/2)
227}
228
229func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
230	row, col := c.Position()
231	offset := row + 3
232	cursor.Y += offset
233	cursor.X = cursor.X + col + 2
234	return cursor
235}
236
237func (c *commandDialogCmp) style() lipgloss.Style {
238	t := styles.CurrentTheme()
239	return t.S().Base.
240		Width(c.width).
241		Border(lipgloss.RoundedBorder()).
242		BorderForeground(t.BorderFocus)
243}
244
245func (c *commandDialogCmp) Position() (int, int) {
246	row := c.wHeight/4 - 2 // just a bit above the center
247	col := c.wWidth / 2
248	col -= c.width / 2
249	return row, col
250}
251
252func (c *commandDialogCmp) defaultCommands() []Command {
253	commands := []Command{
254		{
255			ID:          "new_session",
256			Title:       "New Session",
257			Description: "start a new session",
258			Shortcut:    "ctrl+n",
259			Handler: func(cmd Command) tea.Cmd {
260				return util.CmdHandler(NewSessionsMsg{})
261			},
262		},
263		{
264			ID:          "switch_session",
265			Title:       "Switch Session",
266			Description: "Switch to a different session",
267			Shortcut:    "ctrl+s",
268			Handler: func(cmd Command) tea.Cmd {
269				return util.CmdHandler(SwitchSessionsMsg{})
270			},
271		},
272		{
273			ID:          "switch_model",
274			Title:       "Switch Model",
275			Description: "Switch to a different model",
276			Handler: func(cmd Command) tea.Cmd {
277				return util.CmdHandler(SwitchModelMsg{})
278			},
279		},
280	}
281
282	// Only show compact command if there's an active session
283	if c.sessionID != "" {
284		commands = append(commands, Command{
285			ID:          "Summarize",
286			Title:       "Summarize Session",
287			Description: "Summarize the current session and create a new one with the summary",
288			Handler: func(cmd Command) tea.Cmd {
289				return util.CmdHandler(CompactMsg{
290					SessionID: c.sessionID,
291				})
292			},
293		})
294	}
295
296	// Only show thinking toggle for Anthropic models that can reason
297	cfg := config.Get()
298	if agentCfg, ok := cfg.Agents["coder"]; ok {
299		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
300		model := cfg.GetModelByType(agentCfg.Model)
301		if providerCfg != nil && model != nil &&
302			providerCfg.Type == catwalk.TypeAnthropic && model.CanReason {
303			selectedModel := cfg.Models[agentCfg.Model]
304			status := "Enable"
305			if selectedModel.Think {
306				status = "Disable"
307			}
308			commands = append(commands, Command{
309				ID:          "toggle_thinking",
310				Title:       status + " Thinking Mode",
311				Description: "Toggle model thinking for reasoning-capable models",
312				Handler: func(cmd Command) tea.Cmd {
313					return util.CmdHandler(ToggleThinkingMsg{})
314				},
315			})
316		}
317	}
318
319	// Only show toggle compact mode command if window width is larger than compact breakpoint (90)
320	if c.wWidth > 120 && c.sessionID != "" {
321		commands = append(commands, Command{
322			ID:          "toggle_sidebar",
323			Title:       "Toggle Sidebar",
324			Description: "Toggle between compact and normal layout",
325			Handler: func(cmd Command) tea.Cmd {
326				return util.CmdHandler(ToggleCompactModeMsg{})
327			},
328		})
329	}
330	if c.sessionID != "" {
331		agentCfg := config.Get().Agents["coder"]
332		model := config.Get().GetModelByType(agentCfg.Model)
333		if model.SupportsImages {
334			commands = append(commands, Command{
335				ID:          "file_picker",
336				Title:       "Open File Picker",
337				Shortcut:    "ctrl+f",
338				Description: "Open file picker",
339				Handler: func(cmd Command) tea.Cmd {
340					return util.CmdHandler(OpenFilePickerMsg{})
341				},
342			})
343		}
344	}
345
346	return append(commands, []Command{
347		{
348			ID:          "toggle_help",
349			Title:       "Toggle Help",
350			Shortcut:    "ctrl+g",
351			Description: "Toggle help",
352			Handler: func(cmd Command) tea.Cmd {
353				return util.CmdHandler(ToggleHelpMsg{})
354			},
355		},
356		{
357			ID:          "init",
358			Title:       "Initialize Project",
359			Description: "Create/Update the CRUSH.md memory file",
360			Handler: func(cmd Command) tea.Cmd {
361				return util.CmdHandler(chat.SendMsg{
362					Text: prompt.Initialize(),
363				})
364			},
365		},
366		{
367			ID:          "quit",
368			Title:       "Quit",
369			Description: "Quit",
370			Shortcut:    "ctrl+c",
371			Handler: func(cmd Command) tea.Cmd {
372				return util.CmdHandler(QuitMsg{})
373			},
374		},
375	}...)
376}
377
378func (c *commandDialogCmp) ID() dialogs.DialogID {
379	return CommandsDialogID
380}