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/lipgloss/v2"
8
9 "github.com/charmbracelet/crush/internal/tui/components/chat"
10 "github.com/charmbracelet/crush/internal/tui/components/completions"
11 "github.com/charmbracelet/crush/internal/tui/components/core"
12 "github.com/charmbracelet/crush/internal/tui/components/core/list"
13 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
14 "github.com/charmbracelet/crush/internal/tui/styles"
15 "github.com/charmbracelet/crush/internal/tui/util"
16)
17
18const (
19 CommandsDialogID dialogs.DialogID = "commands"
20
21 defaultWidth int = 70
22)
23
24const (
25 SystemCommands int = iota
26 UserCommands
27)
28
29// Command represents a command that can be executed
30type Command struct {
31 ID string
32 Title string
33 Description string
34 Shortcut string // Optional shortcut for the command
35 Handler func(cmd Command) tea.Cmd
36}
37
38// CommandsDialog represents the commands dialog.
39type CommandsDialog interface {
40 dialogs.DialogModel
41}
42
43type commandDialogCmp struct {
44 width int
45 wWidth int // Width of the terminal window
46 wHeight int // Height of the terminal window
47
48 commandList list.ListModel
49 keyMap CommandsDialogKeyMap
50 help help.Model
51 commandType int // SystemCommands or UserCommands
52 userCommands []Command // User-defined commands
53 sessionID string // Current session ID
54}
55
56type (
57 SwitchSessionsMsg struct{}
58 SwitchModelMsg struct{}
59 ToggleCompactModeMsg struct{}
60 CompactMsg struct {
61 SessionID string
62 }
63)
64
65func NewCommandDialog(sessionID string) CommandsDialog {
66 listKeyMap := list.DefaultKeyMap()
67 keyMap := DefaultCommandsDialogKeyMap()
68
69 listKeyMap.Down.SetEnabled(false)
70 listKeyMap.Up.SetEnabled(false)
71 listKeyMap.NDown.SetEnabled(false)
72 listKeyMap.NUp.SetEnabled(false)
73 listKeyMap.HalfPageDown.SetEnabled(false)
74 listKeyMap.HalfPageUp.SetEnabled(false)
75 listKeyMap.Home.SetEnabled(false)
76 listKeyMap.End.SetEnabled(false)
77
78 listKeyMap.DownOneItem = keyMap.Next
79 listKeyMap.UpOneItem = keyMap.Previous
80
81 t := styles.CurrentTheme()
82 commandList := list.New(
83 list.WithFilterable(true),
84 list.WithKeyMap(listKeyMap),
85 list.WithWrapNavigation(true),
86 )
87 help := help.New()
88 help.Styles = t.S().Help
89 return &commandDialogCmp{
90 commandList: commandList,
91 width: defaultWidth,
92 keyMap: DefaultCommandsDialogKeyMap(),
93 help: help,
94 commandType: SystemCommands,
95 sessionID: sessionID,
96 }
97}
98
99func (c *commandDialogCmp) Init() tea.Cmd {
100 commands, err := LoadCustomCommands()
101 if err != nil {
102 return util.ReportError(err)
103 }
104
105 c.userCommands = commands
106 c.SetCommandType(c.commandType)
107 return c.commandList.Init()
108}
109
110func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
111 switch msg := msg.(type) {
112 case tea.WindowSizeMsg:
113 c.wWidth = msg.Width
114 c.wHeight = msg.Height
115 c.SetCommandType(c.commandType)
116 return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
117 case tea.KeyPressMsg:
118 switch {
119 case key.Matches(msg, c.keyMap.Select):
120 selectedItemInx := c.commandList.SelectedIndex()
121 if selectedItemInx == list.NoSelection {
122 return c, nil // No item selected, do nothing
123 }
124 items := c.commandList.Items()
125 selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(Command)
126 return c, tea.Sequence(
127 util.CmdHandler(dialogs.CloseDialogMsg{}),
128 selectedItem.Handler(selectedItem),
129 )
130 case key.Matches(msg, c.keyMap.Tab):
131 // Toggle command type between System and User commands
132 if c.commandType == SystemCommands {
133 return c, c.SetCommandType(UserCommands)
134 } else {
135 return c, c.SetCommandType(SystemCommands)
136 }
137 case key.Matches(msg, c.keyMap.Close):
138 return c, util.CmdHandler(dialogs.CloseDialogMsg{})
139 default:
140 u, cmd := c.commandList.Update(msg)
141 c.commandList = u.(list.ListModel)
142 return c, cmd
143 }
144 }
145 return c, nil
146}
147
148func (c *commandDialogCmp) View() tea.View {
149 t := styles.CurrentTheme()
150 listView := c.commandList.View()
151 radio := c.commandTypeRadio()
152 content := lipgloss.JoinVertical(
153 lipgloss.Left,
154 t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5)+" "+radio),
155 listView.String(),
156 "",
157 t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
158 )
159 v := tea.NewView(c.style().Render(content))
160 if listView.Cursor() != nil {
161 c := c.moveCursor(listView.Cursor())
162 v.SetCursor(c)
163 }
164 return v
165}
166
167func (c *commandDialogCmp) commandTypeRadio() string {
168 t := styles.CurrentTheme()
169 choices := []string{"System", "User"}
170 iconSelected := "◉"
171 iconUnselected := "○"
172 if c.commandType == SystemCommands {
173 return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
174 }
175 return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
176}
177
178func (c *commandDialogCmp) listWidth() int {
179 return defaultWidth - 2 // 4 for padding
180}
181
182func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd {
183 c.commandType = commandType
184
185 var commands []Command
186 if c.commandType == SystemCommands {
187 commands = c.defaultCommands()
188 } else {
189 commands = c.userCommands
190 }
191
192 commandItems := []util.Model{}
193 for _, cmd := range commands {
194 opts := []completions.CompletionOption{}
195 if cmd.Shortcut != "" {
196 opts = append(opts, completions.WithShortcut(cmd.Shortcut))
197 }
198 commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd, opts...))
199 }
200 return c.commandList.SetItems(commandItems)
201}
202
203func (c *commandDialogCmp) listHeight() int {
204 listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
205 return min(listHeigh, c.wHeight/2)
206}
207
208func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
209 row, col := c.Position()
210 offset := row + 3
211 cursor.Y += offset
212 cursor.X = cursor.X + col + 2
213 return cursor
214}
215
216func (c *commandDialogCmp) style() lipgloss.Style {
217 t := styles.CurrentTheme()
218 return t.S().Base.
219 Width(c.width).
220 Border(lipgloss.RoundedBorder()).
221 BorderForeground(t.BorderFocus)
222}
223
224func (c *commandDialogCmp) Position() (int, int) {
225 row := c.wHeight/4 - 2 // just a bit above the center
226 col := c.wWidth / 2
227 col -= c.width / 2
228 return row, col
229}
230
231func (c *commandDialogCmp) defaultCommands() []Command {
232 commands := []Command{
233 {
234 ID: "init",
235 Title: "Initialize Project",
236 Description: "Create/Update the CRUSH.md memory file",
237 Handler: func(cmd Command) tea.Cmd {
238 prompt := `Please analyze this codebase and create a CRUSH.md file containing:
239 1. Build/lint/test commands - especially for running a single test
240 2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
241
242 The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
243 If there's already a CRUSH.md, improve it.
244 If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.
245 Add the .crush directory to the .gitignore file if it's not already there.`
246 return util.CmdHandler(chat.SendMsg{
247 Text: prompt,
248 })
249 },
250 },
251 }
252
253 // Only show compact command if there's an active session
254 if c.sessionID != "" {
255 commands = append(commands, Command{
256 ID: "Summarize",
257 Title: "Summarize Session",
258 Description: "Summarize the current session and create a new one with the summary",
259 Handler: func(cmd Command) tea.Cmd {
260 return util.CmdHandler(CompactMsg{
261 SessionID: c.sessionID,
262 })
263 },
264 })
265 }
266 // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
267 if c.wWidth > 90 && c.sessionID != "" {
268 commands = append(commands, Command{
269 ID: "toggle_sidebar",
270 Title: "Toggle Sidebar",
271 Description: "Toggle between compact and normal layout",
272 Handler: func(cmd Command) tea.Cmd {
273 return util.CmdHandler(ToggleCompactModeMsg{})
274 },
275 })
276 }
277
278 return append(commands, []Command{
279 {
280 ID: "switch_session",
281 Title: "Switch Session",
282 Description: "Switch to a different session",
283 Shortcut: "ctrl+s",
284 Handler: func(cmd Command) tea.Cmd {
285 return util.CmdHandler(SwitchSessionsMsg{})
286 },
287 },
288 {
289 ID: "switch_model",
290 Title: "Switch Model",
291 Description: "Switch to a different model",
292 Handler: func(cmd Command) tea.Cmd {
293 return util.CmdHandler(SwitchModelMsg{})
294 },
295 },
296 }...)
297}
298
299func (c *commandDialogCmp) ID() dialogs.DialogID {
300 return CommandsDialogID
301}