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	SwitchModelMsg       struct{}
 63	ToggleCompactModeMsg struct{}
 64	ToggleThinkingMsg    struct{}
 65	CompactMsg           struct {
 66		SessionID string
 67	}
 68)
 69
 70func NewCommandDialog(sessionID string) CommandsDialog {
 71	keyMap := DefaultCommandsDialogKeyMap()
 72	listKeyMap := list.DefaultKeyMap()
 73	listKeyMap.Down.SetEnabled(false)
 74	listKeyMap.Up.SetEnabled(false)
 75	listKeyMap.DownOneItem = keyMap.Next
 76	listKeyMap.UpOneItem = keyMap.Previous
 77
 78	t := styles.CurrentTheme()
 79	inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
 80	commandList := list.NewFilterableList(
 81		[]list.CompletionItem[Command]{},
 82		list.WithFilterInputStyle(inputStyle),
 83		list.WithFilterListOptions(
 84			list.WithKeyMap(listKeyMap),
 85			list.WithWrapNavigation(),
 86			list.WithResizeByList(),
 87		),
 88	)
 89	help := help.New()
 90	help.Styles = t.S().Help
 91	return &commandDialogCmp{
 92		commandList: commandList,
 93		width:       defaultWidth,
 94		keyMap:      DefaultCommandsDialogKeyMap(),
 95		help:        help,
 96		commandType: SystemCommands,
 97		sessionID:   sessionID,
 98	}
 99}
100
101func (c *commandDialogCmp) Init() tea.Cmd {
102	commands, err := LoadCustomCommands()
103	if err != nil {
104		return util.ReportError(err)
105	}
106	c.userCommands = commands
107	return c.SetCommandType(c.commandType)
108}
109
110func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
111	switch msg := msg.(type) {
112	case tea.WindowSizeMsg:
113		c.wWidth = msg.Width
114		c.wHeight = msg.Height
115		return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
116	case tea.KeyPressMsg:
117		switch {
118		case key.Matches(msg, c.keyMap.Select):
119			selectedItem := c.commandList.SelectedItem()
120			if selectedItem == nil {
121				return c, nil // No item selected, do nothing
122			}
123			command := (*selectedItem).Value()
124			return c, tea.Sequence(
125				util.CmdHandler(dialogs.CloseDialogMsg{}),
126				command.Handler(command),
127			)
128		case key.Matches(msg, c.keyMap.Tab):
129			if len(c.userCommands) == 0 {
130				return c, nil
131			}
132			// Toggle command type between System and User commands
133			if c.commandType == SystemCommands {
134				return c, c.SetCommandType(UserCommands)
135			} else {
136				return c, c.SetCommandType(SystemCommands)
137			}
138		case key.Matches(msg, c.keyMap.Close):
139			return c, util.CmdHandler(dialogs.CloseDialogMsg{})
140		default:
141			u, cmd := c.commandList.Update(msg)
142			c.commandList = u.(listModel)
143			return c, cmd
144		}
145	}
146	return c, nil
147}
148
149func (c *commandDialogCmp) View() string {
150	t := styles.CurrentTheme()
151	listView := c.commandList
152	radio := c.commandTypeRadio()
153
154	header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5) + " " + radio)
155	if len(c.userCommands) == 0 {
156		header = t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-4))
157	}
158	content := lipgloss.JoinVertical(
159		lipgloss.Left,
160		header,
161		listView.View(),
162		"",
163		t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
164	)
165	return c.style().Render(content)
166}
167
168func (c *commandDialogCmp) Cursor() *tea.Cursor {
169	if cursor, ok := c.commandList.(util.Cursor); ok {
170		cursor := cursor.Cursor()
171		if cursor != nil {
172			cursor = c.moveCursor(cursor)
173		}
174		return cursor
175	}
176	return nil
177}
178
179func (c *commandDialogCmp) commandTypeRadio() string {
180	t := styles.CurrentTheme()
181	choices := []string{"System", "User"}
182	iconSelected := "◉"
183	iconUnselected := "○"
184	if c.commandType == SystemCommands {
185		return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
186	}
187	return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
188}
189
190func (c *commandDialogCmp) listWidth() int {
191	return defaultWidth - 2 // 4 for padding
192}
193
194func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd {
195	c.commandType = commandType
196
197	var commands []Command
198	if c.commandType == SystemCommands {
199		commands = c.defaultCommands()
200	} else {
201		commands = c.userCommands
202	}
203
204	commandItems := []list.CompletionItem[Command]{}
205	for _, cmd := range commands {
206		opts := []list.CompletionItemOption{
207			list.WithCompletionID(cmd.ID),
208		}
209		if cmd.Shortcut != "" {
210			opts = append(
211				opts,
212				list.WithCompletionShortcut(cmd.Shortcut),
213			)
214		}
215		commandItems = append(commandItems, list.NewCompletionItem(cmd.Title, cmd, opts...))
216	}
217	return c.commandList.SetItems(commandItems)
218}
219
220func (c *commandDialogCmp) listHeight() int {
221	listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
222	return min(listHeigh, c.wHeight/2)
223}
224
225func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
226	row, col := c.Position()
227	offset := row + 3
228	cursor.Y += offset
229	cursor.X = cursor.X + col + 2
230	return cursor
231}
232
233func (c *commandDialogCmp) style() lipgloss.Style {
234	t := styles.CurrentTheme()
235	return t.S().Base.
236		Width(c.width).
237		Border(lipgloss.RoundedBorder()).
238		BorderForeground(t.BorderFocus)
239}
240
241func (c *commandDialogCmp) Position() (int, int) {
242	row := c.wHeight/4 - 2 // just a bit above the center
243	col := c.wWidth / 2
244	col -= c.width / 2
245	return row, col
246}
247
248func (c *commandDialogCmp) defaultCommands() []Command {
249	commands := []Command{
250		{
251			ID:          "init",
252			Title:       "Initialize Project",
253			Description: "Create/Update the CRUSH.md memory file",
254			Handler: func(cmd Command) tea.Cmd {
255				return util.CmdHandler(chat.SendMsg{
256					Text: prompt.Initialize(),
257				})
258			},
259		},
260	}
261
262	// Only show compact command if there's an active session
263	if c.sessionID != "" {
264		commands = append(commands, Command{
265			ID:          "Summarize",
266			Title:       "Summarize Session",
267			Description: "Summarize the current session and create a new one with the summary",
268			Handler: func(cmd Command) tea.Cmd {
269				return util.CmdHandler(CompactMsg{
270					SessionID: c.sessionID,
271				})
272			},
273		})
274	}
275
276	// Only show thinking toggle for Anthropic models that can reason
277	cfg := config.Get()
278	if agentCfg, ok := cfg.Agents["coder"]; ok {
279		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
280		model := cfg.GetModelByType(agentCfg.Model)
281		if providerCfg != nil && model != nil &&
282			providerCfg.Type == catwalk.TypeAnthropic && model.CanReason {
283			selectedModel := cfg.Models[agentCfg.Model]
284			status := "Enable"
285			if selectedModel.Think {
286				status = "Disable"
287			}
288			commands = append(commands, Command{
289				ID:          "toggle_thinking",
290				Title:       status + " Thinking Mode",
291				Description: "Toggle model thinking for reasoning-capable models",
292				Handler: func(cmd Command) tea.Cmd {
293					return util.CmdHandler(ToggleThinkingMsg{})
294				},
295			})
296		}
297	}
298
299	// Only show toggle compact mode command if window width is larger than compact breakpoint (90)
300	if c.wWidth > 120 && c.sessionID != "" {
301		commands = append(commands, Command{
302			ID:          "toggle_sidebar",
303			Title:       "Toggle Sidebar",
304			Description: "Toggle between compact and normal layout",
305			Handler: func(cmd Command) tea.Cmd {
306				return util.CmdHandler(ToggleCompactModeMsg{})
307			},
308		})
309	}
310
311	return append(commands, []Command{
312		{
313			ID:          "switch_session",
314			Title:       "Switch Session",
315			Description: "Switch to a different session",
316			Shortcut:    "ctrl+s",
317			Handler: func(cmd Command) tea.Cmd {
318				return util.CmdHandler(SwitchSessionsMsg{})
319			},
320		},
321		{
322			ID:          "switch_model",
323			Title:       "Switch Model",
324			Description: "Switch to a different model",
325			Handler: func(cmd Command) tea.Cmd {
326				return util.CmdHandler(SwitchModelMsg{})
327			},
328		},
329	}...)
330}
331
332func (c *commandDialogCmp) ID() dialogs.DialogID {
333	return CommandsDialogID
334}