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 CompactMsg struct {
60 SessionID string
61 }
62)
63
64func NewCommandDialog(sessionID string) CommandsDialog {
65 listKeyMap := list.DefaultKeyMap()
66 keyMap := DefaultCommandsDialogKeyMap()
67
68 listKeyMap.Down.SetEnabled(false)
69 listKeyMap.Up.SetEnabled(false)
70 listKeyMap.NDown.SetEnabled(false)
71 listKeyMap.NUp.SetEnabled(false)
72 listKeyMap.HalfPageDown.SetEnabled(false)
73 listKeyMap.HalfPageUp.SetEnabled(false)
74 listKeyMap.Home.SetEnabled(false)
75 listKeyMap.End.SetEnabled(false)
76
77 listKeyMap.DownOneItem = keyMap.Next
78 listKeyMap.UpOneItem = keyMap.Previous
79
80 t := styles.CurrentTheme()
81 commandList := list.New(
82 list.WithFilterable(true),
83 list.WithKeyMap(listKeyMap),
84 list.WithWrapNavigation(true),
85 )
86 help := help.New()
87 help.Styles = t.S().Help
88 return &commandDialogCmp{
89 commandList: commandList,
90 width: defaultWidth,
91 keyMap: DefaultCommandsDialogKeyMap(),
92 help: help,
93 commandType: SystemCommands,
94 sessionID: sessionID,
95 }
96}
97
98func (c *commandDialogCmp) Init() tea.Cmd {
99 commands, err := LoadCustomCommands()
100 if err != nil {
101 return util.ReportError(err)
102 }
103
104 c.userCommands = commands
105 c.SetCommandType(c.commandType)
106 return c.commandList.Init()
107}
108
109func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
110 switch msg := msg.(type) {
111 case tea.WindowSizeMsg:
112 c.wWidth = msg.Width
113 c.wHeight = msg.Height
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: "compact",
255 Title: "Compact 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
265 return append(commands, []Command{
266 {
267 ID: "switch_session",
268 Title: "Switch Session",
269 Description: "Switch to a different session",
270 Shortcut: "ctrl+s",
271 Handler: func(cmd Command) tea.Cmd {
272 return util.CmdHandler(SwitchSessionsMsg{})
273 },
274 },
275 {
276 ID: "switch_model",
277 Title: "Switch Model",
278 Description: "Switch to a different model",
279 Handler: func(cmd Command) tea.Cmd {
280 return util.CmdHandler(SwitchModelMsg{})
281 },
282 },
283 }...)
284}
285
286func (c *commandDialogCmp) ID() dialogs.DialogID {
287 return CommandsDialogID
288}