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