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() tea.View {
147 t := styles.CurrentTheme()
148 listView := c.commandList.View()
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.String(),
154 "",
155 t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
156 )
157 v := tea.NewView(c.style().Render(content))
158 if listView.Cursor() != nil {
159 c := c.moveCursor(listView.Cursor())
160 v.SetCursor(c)
161 }
162 return v
163}
164
165func (c *commandDialogCmp) commandTypeRadio() string {
166 t := styles.CurrentTheme()
167 choices := []string{"System", "User"}
168 iconSelected := "◉"
169 iconUnselected := "○"
170 if c.commandType == SystemCommands {
171 return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
172 }
173 return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
174}
175
176func (c *commandDialogCmp) listWidth() int {
177 return defaultWidth - 2 // 4 for padding
178}
179
180func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd {
181 c.commandType = commandType
182
183 var commands []Command
184 if c.commandType == SystemCommands {
185 commands = c.defaultCommands()
186 } else {
187 commands = c.userCommands
188 }
189
190 commandItems := []util.Model{}
191 for _, cmd := range commands {
192 opts := []completions.CompletionOption{}
193 if cmd.Shortcut != "" {
194 opts = append(opts, completions.WithShortcut(cmd.Shortcut))
195 }
196 commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd, opts...))
197 }
198 return c.commandList.SetItems(commandItems)
199}
200
201func (c *commandDialogCmp) listHeight() int {
202 listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
203 return min(listHeigh, c.wHeight/2)
204}
205
206func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
207 row, col := c.Position()
208 offset := row + 3
209 cursor.Y += offset
210 cursor.X = cursor.X + col + 2
211 return cursor
212}
213
214func (c *commandDialogCmp) style() lipgloss.Style {
215 t := styles.CurrentTheme()
216 return t.S().Base.
217 Width(c.width).
218 Border(lipgloss.RoundedBorder()).
219 BorderForeground(t.BorderFocus)
220}
221
222func (c *commandDialogCmp) Position() (int, int) {
223 row := c.wHeight/4 - 2 // just a bit above the center
224 col := c.wWidth / 2
225 col -= c.width / 2
226 return row, col
227}
228
229func (c *commandDialogCmp) defaultCommands() []Command {
230 commands := []Command{
231 {
232 ID: "init",
233 Title: "Initialize Project",
234 Description: "Create/Update the CRUSH.md memory file",
235 Handler: func(cmd Command) tea.Cmd {
236 prompt := `Please analyze this codebase and create a CRUSH.md file containing:
237 1. Build/lint/test commands - especially for running a single test
238 2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
239
240 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.
241 If there's already a CRUSH.md, improve it.
242 If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.
243 Add the .crush directory to the .gitignore file if it's not already there.`
244 return util.CmdHandler(chat.SendMsg{
245 Text: prompt,
246 })
247 },
248 },
249 }
250
251 // Only show compact command if there's an active session
252 if c.sessionID != "" {
253 commands = append(commands, Command{
254 ID: "Summarize",
255 Title: "Summarize Session",
256 Description: "Summarize the current session and create a new one with the summary",
257 Handler: func(cmd Command) tea.Cmd {
258 return util.CmdHandler(CompactMsg{
259 SessionID: c.sessionID,
260 })
261 },
262 })
263 }
264 // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
265 if c.wWidth > 120 && c.sessionID != "" {
266 commands = append(commands, Command{
267 ID: "toggle_sidebar",
268 Title: "Toggle Sidebar",
269 Description: "Toggle between compact and normal layout",
270 Handler: func(cmd Command) tea.Cmd {
271 return util.CmdHandler(ToggleCompactModeMsg{})
272 },
273 })
274 }
275
276 return append(commands, []Command{
277 {
278 ID: "switch_session",
279 Title: "Switch Session",
280 Description: "Switch to a different session",
281 Shortcut: "ctrl+s",
282 Handler: func(cmd Command) tea.Cmd {
283 return util.CmdHandler(SwitchSessionsMsg{})
284 },
285 },
286 {
287 ID: "switch_model",
288 Title: "Switch Model",
289 Description: "Switch to a different model",
290 Handler: func(cmd Command) tea.Cmd {
291 return util.CmdHandler(SwitchModelMsg{})
292 },
293 },
294 }...)
295}
296
297func (c *commandDialogCmp) ID() dialogs.DialogID {
298 return CommandsDialogID
299}