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