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/config"
10 "github.com/charmbracelet/crush/internal/fur/provider"
11 "github.com/charmbracelet/crush/internal/llm/prompt"
12 "github.com/charmbracelet/crush/internal/tui/components/chat"
13 "github.com/charmbracelet/crush/internal/tui/components/completions"
14 "github.com/charmbracelet/crush/internal/tui/components/core"
15 "github.com/charmbracelet/crush/internal/tui/components/core/list"
16 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
17 "github.com/charmbracelet/crush/internal/tui/styles"
18 "github.com/charmbracelet/crush/internal/tui/util"
19)
20
21const (
22 CommandsDialogID dialogs.DialogID = "commands"
23
24 defaultWidth int = 70
25)
26
27const (
28 SystemCommands int = iota
29 UserCommands
30)
31
32// Command represents a command that can be executed
33type Command struct {
34 ID string
35 Title string
36 Description string
37 Shortcut string // Optional shortcut for the command
38 Handler func(cmd Command) tea.Cmd
39}
40
41// CommandsDialog represents the commands dialog.
42type CommandsDialog interface {
43 dialogs.DialogModel
44}
45
46type commandDialogCmp struct {
47 width int
48 wWidth int // Width of the terminal window
49 wHeight int // Height of the terminal window
50
51 commandList list.ListModel
52 keyMap CommandsDialogKeyMap
53 help help.Model
54 commandType int // SystemCommands or UserCommands
55 userCommands []Command // User-defined commands
56 sessionID string // Current session ID
57}
58
59type (
60 SwitchSessionsMsg struct{}
61 SwitchModelMsg struct{}
62 ToggleCompactModeMsg struct{}
63 ToggleThinkingMsg struct{}
64 CompactMsg struct {
65 SessionID string
66 }
67)
68
69func NewCommandDialog(sessionID string) CommandsDialog {
70 listKeyMap := list.DefaultKeyMap()
71 keyMap := DefaultCommandsDialogKeyMap()
72
73 listKeyMap.Down.SetEnabled(false)
74 listKeyMap.Up.SetEnabled(false)
75 listKeyMap.HalfPageDown.SetEnabled(false)
76 listKeyMap.HalfPageUp.SetEnabled(false)
77 listKeyMap.Home.SetEnabled(false)
78 listKeyMap.End.SetEnabled(false)
79
80 listKeyMap.DownOneItem = keyMap.Next
81 listKeyMap.UpOneItem = keyMap.Previous
82
83 t := styles.CurrentTheme()
84 commandList := list.New(
85 list.WithFilterable(true),
86 list.WithKeyMap(listKeyMap),
87 list.WithWrapNavigation(true),
88 )
89 help := help.New()
90 help.Styles = t.S().Help
91 return &commandDialogCmp{
92 commandList: commandList,
93 width: defaultWidth,
94 keyMap: DefaultCommandsDialogKeyMap(),
95 help: help,
96 commandType: SystemCommands,
97 sessionID: sessionID,
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() string {
151 t := styles.CurrentTheme()
152 listView := c.commandList
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.View(),
158 "",
159 t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
160 )
161 return c.style().Render(content)
162}
163
164func (c *commandDialogCmp) Cursor() *tea.Cursor {
165 if cursor, ok := c.commandList.(util.Cursor); ok {
166 cursor := cursor.Cursor()
167 if cursor != nil {
168 cursor = c.moveCursor(cursor)
169 }
170 return cursor
171 }
172 return nil
173}
174
175func (c *commandDialogCmp) commandTypeRadio() string {
176 t := styles.CurrentTheme()
177 choices := []string{"System", "User"}
178 iconSelected := "◉"
179 iconUnselected := "○"
180 if c.commandType == SystemCommands {
181 return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
182 }
183 return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
184}
185
186func (c *commandDialogCmp) listWidth() int {
187 return defaultWidth - 2 // 4 for padding
188}
189
190func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd {
191 c.commandType = commandType
192
193 var commands []Command
194 if c.commandType == SystemCommands {
195 commands = c.defaultCommands()
196 } else {
197 commands = c.userCommands
198 }
199
200 commandItems := []util.Model{}
201 for _, cmd := range commands {
202 opts := []completions.CompletionOption{}
203 if cmd.Shortcut != "" {
204 opts = append(opts, completions.WithShortcut(cmd.Shortcut))
205 }
206 commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd, opts...))
207 }
208 return c.commandList.SetItems(commandItems)
209}
210
211func (c *commandDialogCmp) listHeight() int {
212 listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
213 return min(listHeigh, c.wHeight/2)
214}
215
216func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
217 row, col := c.Position()
218 offset := row + 3
219 cursor.Y += offset
220 cursor.X = cursor.X + col + 2
221 return cursor
222}
223
224func (c *commandDialogCmp) style() lipgloss.Style {
225 t := styles.CurrentTheme()
226 return t.S().Base.
227 Width(c.width).
228 Border(lipgloss.RoundedBorder()).
229 BorderForeground(t.BorderFocus)
230}
231
232func (c *commandDialogCmp) Position() (int, int) {
233 row := c.wHeight/4 - 2 // just a bit above the center
234 col := c.wWidth / 2
235 col -= c.width / 2
236 return row, col
237}
238
239func (c *commandDialogCmp) defaultCommands() []Command {
240 commands := []Command{
241 {
242 ID: "init",
243 Title: "Initialize Project",
244 Description: "Create/Update the CRUSH.md memory file",
245 Handler: func(cmd Command) tea.Cmd {
246 return util.CmdHandler(chat.SendMsg{
247 Text: prompt.Initialize(),
248 })
249 },
250 },
251 }
252
253 // Only show compact command if there's an active session
254 if c.sessionID != "" {
255 commands = append(commands, Command{
256 ID: "Summarize",
257 Title: "Summarize Session",
258 Description: "Summarize the current session and create a new one with the summary",
259 Handler: func(cmd Command) tea.Cmd {
260 return util.CmdHandler(CompactMsg{
261 SessionID: c.sessionID,
262 })
263 },
264 })
265 }
266
267 // Only show thinking toggle for Anthropic models that can reason
268 cfg := config.Get()
269 if agentCfg, ok := cfg.Agents["coder"]; ok {
270 providerCfg := cfg.GetProviderForModel(agentCfg.Model)
271 model := cfg.GetModelByType(agentCfg.Model)
272 if providerCfg != nil && model != nil &&
273 providerCfg.Type == provider.TypeAnthropic && model.CanReason {
274 selectedModel := cfg.Models[agentCfg.Model]
275 status := "Enable"
276 if selectedModel.Think {
277 status = "Disable"
278 }
279 commands = append(commands, Command{
280 ID: "toggle_thinking",
281 Title: status + " Thinking Mode",
282 Description: "Toggle model thinking for reasoning-capable models",
283 Handler: func(cmd Command) tea.Cmd {
284 return util.CmdHandler(ToggleThinkingMsg{})
285 },
286 })
287 }
288 }
289
290 // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
291 if c.wWidth > 120 && c.sessionID != "" {
292 commands = append(commands, Command{
293 ID: "toggle_sidebar",
294 Title: "Toggle Sidebar",
295 Description: "Toggle between compact and normal layout",
296 Handler: func(cmd Command) tea.Cmd {
297 return util.CmdHandler(ToggleCompactModeMsg{})
298 },
299 })
300 }
301
302 return append(commands, []Command{
303 {
304 ID: "switch_session",
305 Title: "Switch Session",
306 Description: "Switch to a different session",
307 Shortcut: "ctrl+s",
308 Handler: func(cmd Command) tea.Cmd {
309 return util.CmdHandler(SwitchSessionsMsg{})
310 },
311 },
312 {
313 ID: "switch_model",
314 Title: "Switch Model",
315 Description: "Switch to a different model",
316 Handler: func(cmd Command) tea.Cmd {
317 return util.CmdHandler(SwitchModelMsg{})
318 },
319 },
320 }...)
321}
322
323func (c *commandDialogCmp) ID() dialogs.DialogID {
324 return CommandsDialogID
325}