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