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