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