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