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