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