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