commands.go

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