commands.go

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