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