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