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