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 if c.selected == SystemCommands {
172 return UserCommands
173 }
174 return SystemCommands
175}
176
177func (c *commandDialogCmp) View() string {
178 t := styles.CurrentTheme()
179 listView := c.commandList
180 radio := c.commandTypeRadio()
181
182 header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5) + " " + radio)
183 if len(c.userCommands) == 0 {
184 header = t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-4))
185 }
186 content := lipgloss.JoinVertical(
187 lipgloss.Left,
188 header,
189 listView.View(),
190 "",
191 t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
192 )
193 return c.style().Render(content)
194}
195
196func (c *commandDialogCmp) Cursor() *tea.Cursor {
197 if cursor, ok := c.commandList.(util.Cursor); ok {
198 cursor := cursor.Cursor()
199 if cursor != nil {
200 cursor = c.moveCursor(cursor)
201 }
202 return cursor
203 }
204 return nil
205}
206
207func (c *commandDialogCmp) commandTypeRadio() string {
208 t := styles.CurrentTheme()
209
210 fn := func(i commandType) string {
211 if i == c.selected {
212 return "◉ " + i.String()
213 }
214 return "○ " + i.String()
215 }
216
217 parts := []string{
218 fn(SystemCommands),
219 }
220 if len(c.userCommands) > 0 {
221 parts = append(parts, fn(UserCommands))
222 }
223 return t.S().Base.Foreground(t.FgHalfMuted).Render(strings.Join(parts, " "))
224}
225
226func (c *commandDialogCmp) listWidth() int {
227 return defaultWidth - 2 // 4 for padding
228}
229
230func (c *commandDialogCmp) setCommandType(commandType commandType) tea.Cmd {
231 c.selected = commandType
232
233 var commands []Command
234 switch c.selected {
235 case SystemCommands:
236 commands = c.defaultCommands()
237 case UserCommands:
238 commands = c.userCommands
239 }
240
241 commandItems := []list.CompletionItem[Command]{}
242 for _, cmd := range commands {
243 opts := []list.CompletionItemOption{
244 list.WithCompletionID(cmd.ID),
245 }
246 if cmd.Shortcut != "" {
247 opts = append(
248 opts,
249 list.WithCompletionShortcut(cmd.Shortcut),
250 )
251 }
252 commandItems = append(commandItems, list.NewCompletionItem(cmd.Title, cmd, opts...))
253 }
254 return c.commandList.SetItems(commandItems)
255}
256
257func (c *commandDialogCmp) listHeight() int {
258 listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
259 return min(listHeigh, c.wHeight/2)
260}
261
262func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
263 row, col := c.Position()
264 offset := row + 3
265 cursor.Y += offset
266 cursor.X = cursor.X + col + 2
267 return cursor
268}
269
270func (c *commandDialogCmp) style() lipgloss.Style {
271 t := styles.CurrentTheme()
272 return t.S().Base.
273 Width(c.width).
274 Border(lipgloss.RoundedBorder()).
275 BorderForeground(t.BorderFocus)
276}
277
278func (c *commandDialogCmp) Position() (int, int) {
279 row := c.wHeight/4 - 2 // just a bit above the center
280 col := c.wWidth / 2
281 col -= c.width / 2
282 return row, col
283}
284
285func (c *commandDialogCmp) defaultCommands() []Command {
286 commands := []Command{
287 {
288 ID: "new_session",
289 Title: "New Session",
290 Description: "start a new session",
291 Shortcut: "ctrl+n",
292 Handler: func(cmd Command) tea.Cmd {
293 return util.CmdHandler(NewSessionsMsg{})
294 },
295 },
296 {
297 ID: "switch_session",
298 Title: "Switch Session",
299 Description: "Switch to a different session",
300 Shortcut: "ctrl+s",
301 Handler: func(cmd Command) tea.Cmd {
302 return util.CmdHandler(SwitchSessionsMsg{})
303 },
304 },
305 {
306 ID: "switch_model",
307 Title: "Switch Model",
308 Description: "Switch to a different model",
309 Handler: func(cmd Command) tea.Cmd {
310 return util.CmdHandler(SwitchModelMsg{})
311 },
312 },
313 }
314
315 // Only show compact command if there's an active session
316 if c.sessionID != "" {
317 commands = append(commands, Command{
318 ID: "Summarize",
319 Title: "Summarize Session",
320 Description: "Summarize the current session and create a new one with the summary",
321 Handler: func(cmd Command) tea.Cmd {
322 return util.CmdHandler(CompactMsg{
323 SessionID: c.sessionID,
324 })
325 },
326 })
327 }
328
329 // Add reasoning toggle for models that support it
330 cfg := config.Get()
331 if agentCfg, ok := cfg.Agents["coder"]; ok {
332 providerCfg := cfg.GetProviderForModel(agentCfg.Model)
333 model := cfg.GetModelByType(agentCfg.Model)
334 if providerCfg != nil && model != nil && model.CanReason {
335 selectedModel := cfg.Models[agentCfg.Model]
336
337 // Anthropic models: thinking toggle
338 if providerCfg.Type == catwalk.TypeAnthropic {
339 status := "Enable"
340 if selectedModel.Think {
341 status = "Disable"
342 }
343 commands = append(commands, Command{
344 ID: "toggle_thinking",
345 Title: status + " Thinking Mode",
346 Description: "Toggle model thinking for reasoning-capable models",
347 Handler: func(cmd Command) tea.Cmd {
348 return util.CmdHandler(ToggleThinkingMsg{})
349 },
350 })
351 }
352
353 // OpenAI models: reasoning effort dialog
354 if providerCfg.Type == catwalk.TypeOpenAI && model.HasReasoningEffort {
355 commands = append(commands, Command{
356 ID: "select_reasoning_effort",
357 Title: "Select Reasoning Effort",
358 Description: "Choose reasoning effort level (low/medium/high)",
359 Handler: func(cmd Command) tea.Cmd {
360 return util.CmdHandler(OpenReasoningDialogMsg{})
361 },
362 })
363 }
364 }
365 }
366 // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
367 if c.wWidth > 120 && c.sessionID != "" {
368 commands = append(commands, Command{
369 ID: "toggle_sidebar",
370 Title: "Toggle Sidebar",
371 Description: "Toggle between compact and normal layout",
372 Handler: func(cmd Command) tea.Cmd {
373 return util.CmdHandler(ToggleCompactModeMsg{})
374 },
375 })
376 }
377 if c.sessionID != "" {
378 agentCfg := config.Get().Agents["coder"]
379 model := config.Get().GetModelByType(agentCfg.Model)
380 if model.SupportsImages {
381 commands = append(commands, Command{
382 ID: "file_picker",
383 Title: "Open File Picker",
384 Shortcut: "ctrl+f",
385 Description: "Open file picker",
386 Handler: func(cmd Command) tea.Cmd {
387 return util.CmdHandler(OpenFilePickerMsg{})
388 },
389 })
390 }
391 }
392
393 // Add external editor command if $EDITOR is available
394 if os.Getenv("EDITOR") != "" {
395 commands = append(commands, Command{
396 ID: "open_external_editor",
397 Title: "Open External Editor",
398 Shortcut: "ctrl+o",
399 Description: "Open external editor to compose message",
400 Handler: func(cmd Command) tea.Cmd {
401 return util.CmdHandler(OpenExternalEditorMsg{})
402 },
403 })
404 }
405
406 return append(commands, []Command{
407 {
408 ID: "toggle_yolo",
409 Title: "Toggle Yolo Mode",
410 Description: "Toggle yolo mode",
411 Handler: func(cmd Command) tea.Cmd {
412 return util.CmdHandler(ToggleYoloModeMsg{})
413 },
414 },
415 {
416 ID: "toggle_help",
417 Title: "Toggle Help",
418 Shortcut: "ctrl+g",
419 Description: "Toggle help",
420 Handler: func(cmd Command) tea.Cmd {
421 return util.CmdHandler(ToggleHelpMsg{})
422 },
423 },
424 {
425 ID: "init",
426 Title: "Initialize Project",
427 Description: "Create/Update the CRUSH.md memory file",
428 Handler: func(cmd Command) tea.Cmd {
429 return util.CmdHandler(chat.SendMsg{
430 Text: prompt.Initialize(),
431 })
432 },
433 },
434 {
435 ID: "quit",
436 Title: "Quit",
437 Description: "Quit",
438 Shortcut: "ctrl+c",
439 Handler: func(cmd Command) tea.Cmd {
440 return util.CmdHandler(QuitMsg{})
441 },
442 },
443 }...)
444}
445
446func (c *commandDialogCmp) ID() dialogs.DialogID {
447 return CommandsDialogID
448}