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