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