commands.go

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