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