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/llm/prompt"
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}
56
57type (
58 SwitchSessionsMsg struct{}
59 SwitchModelMsg struct{}
60 ToggleCompactModeMsg struct{}
61 CompactMsg struct {
62 SessionID string
63 }
64)
65
66func NewCommandDialog(sessionID string) CommandsDialog {
67 listKeyMap := list.DefaultKeyMap()
68 keyMap := DefaultCommandsDialogKeyMap()
69
70 listKeyMap.Down.SetEnabled(false)
71 listKeyMap.Up.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 c.SetCommandType(c.commandType)
115 return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
116 case tea.KeyPressMsg:
117 switch {
118 case key.Matches(msg, c.keyMap.Select):
119 selectedItemInx := c.commandList.SelectedIndex()
120 if selectedItemInx == list.NoSelection {
121 return c, nil // No item selected, do nothing
122 }
123 items := c.commandList.Items()
124 selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(Command)
125 return c, tea.Sequence(
126 util.CmdHandler(dialogs.CloseDialogMsg{}),
127 selectedItem.Handler(selectedItem),
128 )
129 case key.Matches(msg, c.keyMap.Tab):
130 // Toggle command type between System and User commands
131 if c.commandType == SystemCommands {
132 return c, c.SetCommandType(UserCommands)
133 } else {
134 return c, c.SetCommandType(SystemCommands)
135 }
136 case key.Matches(msg, c.keyMap.Close):
137 return c, util.CmdHandler(dialogs.CloseDialogMsg{})
138 default:
139 u, cmd := c.commandList.Update(msg)
140 c.commandList = u.(list.ListModel)
141 return c, cmd
142 }
143 }
144 return c, nil
145}
146
147func (c *commandDialogCmp) View() string {
148 t := styles.CurrentTheme()
149 listView := c.commandList
150 radio := c.commandTypeRadio()
151 content := lipgloss.JoinVertical(
152 lipgloss.Left,
153 t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5)+" "+radio),
154 listView.View(),
155 "",
156 t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
157 )
158 return c.style().Render(content)
159}
160
161func (c *commandDialogCmp) Cursor() *tea.Cursor {
162 if cursor, ok := c.commandList.(util.Cursor); ok {
163 cursor := cursor.Cursor()
164 if cursor != nil {
165 cursor = c.moveCursor(cursor)
166 }
167 return cursor
168 }
169 return nil
170}
171
172func (c *commandDialogCmp) commandTypeRadio() string {
173 t := styles.CurrentTheme()
174 choices := []string{"System", "User"}
175 iconSelected := "◉"
176 iconUnselected := "○"
177 if c.commandType == SystemCommands {
178 return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
179 }
180 return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
181}
182
183func (c *commandDialogCmp) listWidth() int {
184 return defaultWidth - 2 // 4 for padding
185}
186
187func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd {
188 c.commandType = commandType
189
190 var commands []Command
191 if c.commandType == SystemCommands {
192 commands = c.defaultCommands()
193 } else {
194 commands = c.userCommands
195 }
196
197 commandItems := []util.Model{}
198 for _, cmd := range commands {
199 opts := []completions.CompletionOption{}
200 if cmd.Shortcut != "" {
201 opts = append(opts, completions.WithShortcut(cmd.Shortcut))
202 }
203 commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd, opts...))
204 }
205 return c.commandList.SetItems(commandItems)
206}
207
208func (c *commandDialogCmp) listHeight() int {
209 listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
210 return min(listHeigh, c.wHeight/2)
211}
212
213func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
214 row, col := c.Position()
215 offset := row + 3
216 cursor.Y += offset
217 cursor.X = cursor.X + col + 2
218 return cursor
219}
220
221func (c *commandDialogCmp) style() lipgloss.Style {
222 t := styles.CurrentTheme()
223 return t.S().Base.
224 Width(c.width).
225 Border(lipgloss.RoundedBorder()).
226 BorderForeground(t.BorderFocus)
227}
228
229func (c *commandDialogCmp) Position() (int, int) {
230 row := c.wHeight/4 - 2 // just a bit above the center
231 col := c.wWidth / 2
232 col -= c.width / 2
233 return row, col
234}
235
236func (c *commandDialogCmp) defaultCommands() []Command {
237 commands := []Command{
238 {
239 ID: "init",
240 Title: "Initialize Project",
241 Description: "Create/Update the CRUSH.md memory file",
242 Handler: func(cmd Command) tea.Cmd {
243 return util.CmdHandler(chat.SendMsg{
244 Text: prompt.Initialize(),
245 })
246 },
247 },
248 }
249
250 // Only show compact command if there's an active session
251 if c.sessionID != "" {
252 commands = append(commands, Command{
253 ID: "Summarize",
254 Title: "Summarize Session",
255 Description: "Summarize the current session and create a new one with the summary",
256 Handler: func(cmd Command) tea.Cmd {
257 return util.CmdHandler(CompactMsg{
258 SessionID: c.sessionID,
259 })
260 },
261 })
262 }
263 // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
264 if c.wWidth > 120 && c.sessionID != "" {
265 commands = append(commands, Command{
266 ID: "toggle_sidebar",
267 Title: "Toggle Sidebar",
268 Description: "Toggle between compact and normal layout",
269 Handler: func(cmd Command) tea.Cmd {
270 return util.CmdHandler(ToggleCompactModeMsg{})
271 },
272 })
273 }
274
275 return append(commands, []Command{
276 {
277 ID: "switch_session",
278 Title: "Switch Session",
279 Description: "Switch to a different session",
280 Shortcut: "ctrl+s",
281 Handler: func(cmd Command) tea.Cmd {
282 return util.CmdHandler(SwitchSessionsMsg{})
283 },
284 },
285 {
286 ID: "switch_model",
287 Title: "Switch Model",
288 Description: "Switch to a different model",
289 Handler: func(cmd Command) tea.Cmd {
290 return util.CmdHandler(SwitchModelMsg{})
291 },
292 },
293 }...)
294}
295
296func (c *commandDialogCmp) ID() dialogs.DialogID {
297 return CommandsDialogID
298}