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