1package commands
  2
  3import (
  4	"os"
  5
  6	"github.com/charmbracelet/bubbles/v2/help"
  7	"github.com/charmbracelet/bubbles/v2/key"
  8	tea "github.com/charmbracelet/bubbletea/v2"
  9	"github.com/charmbracelet/catwalk/pkg/catwalk"
 10	"github.com/charmbracelet/lipgloss/v2"
 11
 12	"github.com/charmbracelet/crush/internal/config"
 13	"github.com/charmbracelet/crush/internal/llm/prompt"
 14	"github.com/charmbracelet/crush/internal/tui/components/chat"
 15	"github.com/charmbracelet/crush/internal/tui/components/core"
 16	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 17	"github.com/charmbracelet/crush/internal/tui/exp/list"
 18	"github.com/charmbracelet/crush/internal/tui/styles"
 19	"github.com/charmbracelet/crush/internal/tui/util"
 20)
 21
 22const (
 23	CommandsDialogID dialogs.DialogID = "commands"
 24
 25	defaultWidth int = 70
 26)
 27
 28const (
 29	SystemCommands int = iota
 30	UserCommands
 31)
 32
 33type listModel = list.FilterableList[list.CompletionItem[Command]]
 34
 35// Command represents a command that can be executed
 36type Command struct {
 37	ID          string
 38	Title       string
 39	Description string
 40	Shortcut    string // Optional shortcut for the command
 41	Handler     func(cmd Command) tea.Cmd
 42}
 43
 44// CommandsDialog represents the commands dialog.
 45type CommandsDialog interface {
 46	dialogs.DialogModel
 47}
 48
 49type commandDialogCmp struct {
 50	width   int
 51	wWidth  int // Width of the terminal window
 52	wHeight int // Height of the terminal window
 53
 54	commandList  listModel
 55	keyMap       CommandsDialogKeyMap
 56	help         help.Model
 57	commandType  int       // SystemCommands or UserCommands
 58	userCommands []Command // User-defined commands
 59	sessionID    string    // Current session ID
 60}
 61
 62type (
 63	SwitchSessionsMsg     struct{}
 64	NewSessionsMsg        struct{}
 65	SwitchModelMsg        struct{}
 66	QuitMsg               struct{}
 67	OpenFilePickerMsg     struct{}
 68	ToggleHelpMsg         struct{}
 69	ToggleCompactModeMsg  struct{}
 70	ToggleThinkingMsg     struct{}
 71	OpenExternalEditorMsg struct{}
 72	ToggleYoloModeMsg     struct{}
 73	CompactMsg            struct {
 74		SessionID string
 75	}
 76)
 77
 78func NewCommandDialog(sessionID string) CommandsDialog {
 79	keyMap := DefaultCommandsDialogKeyMap()
 80	listKeyMap := list.DefaultKeyMap()
 81	listKeyMap.Down.SetEnabled(false)
 82	listKeyMap.Up.SetEnabled(false)
 83	listKeyMap.DownOneItem = keyMap.Next
 84	listKeyMap.UpOneItem = keyMap.Previous
 85
 86	t := styles.CurrentTheme()
 87	inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
 88	commandList := list.NewFilterableList(
 89		[]list.CompletionItem[Command]{},
 90		list.WithFilterInputStyle(inputStyle),
 91		list.WithFilterListOptions(
 92			list.WithKeyMap(listKeyMap),
 93			list.WithWrapNavigation(),
 94			list.WithResizeByList(),
 95		),
 96	)
 97	help := help.New()
 98	help.Styles = t.S().Help
 99	return &commandDialogCmp{
100		commandList: commandList,
101		width:       defaultWidth,
102		keyMap:      DefaultCommandsDialogKeyMap(),
103		help:        help,
104		commandType: SystemCommands,
105		sessionID:   sessionID,
106	}
107}
108
109func (c *commandDialogCmp) Init() tea.Cmd {
110	commands, err := LoadCustomCommands()
111	if err != nil {
112		return util.ReportError(err)
113	}
114	c.userCommands = commands
115	return c.SetCommandType(c.commandType)
116}
117
118func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
119	switch msg := msg.(type) {
120	case tea.WindowSizeMsg:
121		c.wWidth = msg.Width
122		c.wHeight = msg.Height
123		return c, tea.Batch(
124			c.SetCommandType(c.commandType),
125			c.commandList.SetSize(c.listWidth(), c.listHeight()),
126		)
127	case tea.KeyPressMsg:
128		switch {
129		case key.Matches(msg, c.keyMap.Select):
130			selectedItem := c.commandList.SelectedItem()
131			if selectedItem == nil {
132				return c, nil // No item selected, do nothing
133			}
134			command := (*selectedItem).Value()
135			return c, tea.Sequence(
136				util.CmdHandler(dialogs.CloseDialogMsg{}),
137				command.Handler(command),
138			)
139		case key.Matches(msg, c.keyMap.Tab):
140			if len(c.userCommands) == 0 {
141				return c, nil
142			}
143			// Toggle command type between System and User commands
144			if c.commandType == SystemCommands {
145				return c, c.SetCommandType(UserCommands)
146			} else {
147				return c, c.SetCommandType(SystemCommands)
148			}
149		case key.Matches(msg, c.keyMap.Close):
150			return c, util.CmdHandler(dialogs.CloseDialogMsg{})
151		default:
152			u, cmd := c.commandList.Update(msg)
153			c.commandList = u.(listModel)
154			return c, cmd
155		}
156	}
157	return c, nil
158}
159
160func (c *commandDialogCmp) View() string {
161	t := styles.CurrentTheme()
162	listView := c.commandList
163	radio := c.commandTypeRadio()
164
165	header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5) + " " + radio)
166	if len(c.userCommands) == 0 {
167		header = t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-4))
168	}
169	content := lipgloss.JoinVertical(
170		lipgloss.Left,
171		header,
172		listView.View(),
173		"",
174		t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
175	)
176	return c.style().Render(content)
177}
178
179func (c *commandDialogCmp) Cursor() *tea.Cursor {
180	if cursor, ok := c.commandList.(util.Cursor); ok {
181		cursor := cursor.Cursor()
182		if cursor != nil {
183			cursor = c.moveCursor(cursor)
184		}
185		return cursor
186	}
187	return nil
188}
189
190func (c *commandDialogCmp) commandTypeRadio() string {
191	t := styles.CurrentTheme()
192	choices := []string{"System", "User"}
193	iconSelected := "◉"
194	iconUnselected := "○"
195	if c.commandType == SystemCommands {
196		return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
197	}
198	return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
199}
200
201func (c *commandDialogCmp) listWidth() int {
202	return defaultWidth - 2 // 4 for padding
203}
204
205func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd {
206	c.commandType = commandType
207
208	var commands []Command
209	if c.commandType == SystemCommands {
210		commands = c.defaultCommands()
211	} else {
212		commands = c.userCommands
213	}
214
215	commandItems := []list.CompletionItem[Command]{}
216	for _, cmd := range commands {
217		opts := []list.CompletionItemOption{
218			list.WithCompletionID(cmd.ID),
219		}
220		if cmd.Shortcut != "" {
221			opts = append(
222				opts,
223				list.WithCompletionShortcut(cmd.Shortcut),
224			)
225		}
226		commandItems = append(commandItems, list.NewCompletionItem(cmd.Title, cmd, opts...))
227	}
228	return c.commandList.SetItems(commandItems)
229}
230
231func (c *commandDialogCmp) listHeight() int {
232	listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
233	return min(listHeigh, c.wHeight/2)
234}
235
236func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
237	row, col := c.Position()
238	offset := row + 3
239	cursor.Y += offset
240	cursor.X = cursor.X + col + 2
241	return cursor
242}
243
244func (c *commandDialogCmp) style() lipgloss.Style {
245	t := styles.CurrentTheme()
246	return t.S().Base.
247		Width(c.width).
248		Border(lipgloss.RoundedBorder()).
249		BorderForeground(t.BorderFocus)
250}
251
252func (c *commandDialogCmp) Position() (int, int) {
253	row := c.wHeight/4 - 2 // just a bit above the center
254	col := c.wWidth / 2
255	col -= c.width / 2
256	return row, col
257}
258
259func (c *commandDialogCmp) defaultCommands() []Command {
260	commands := []Command{
261		{
262			ID:          "new_session",
263			Title:       "New Session",
264			Description: "start a new session",
265			Shortcut:    "ctrl+n",
266			Handler: func(cmd Command) tea.Cmd {
267				return util.CmdHandler(NewSessionsMsg{})
268			},
269		},
270		{
271			ID:          "switch_session",
272			Title:       "Switch Session",
273			Description: "Switch to a different session",
274			Shortcut:    "ctrl+s",
275			Handler: func(cmd Command) tea.Cmd {
276				return util.CmdHandler(SwitchSessionsMsg{})
277			},
278		},
279		{
280			ID:          "switch_model",
281			Title:       "Switch Model",
282			Description: "Switch to a different model",
283			Handler: func(cmd Command) tea.Cmd {
284				return util.CmdHandler(SwitchModelMsg{})
285			},
286		},
287	}
288
289	// Only show compact command if there's an active session
290	if c.sessionID != "" {
291		commands = append(commands, Command{
292			ID:          "Summarize",
293			Title:       "Summarize Session",
294			Description: "Summarize the current session and create a new one with the summary",
295			Handler: func(cmd Command) tea.Cmd {
296				return util.CmdHandler(CompactMsg{
297					SessionID: c.sessionID,
298				})
299			},
300		})
301	}
302
303	// Only show thinking toggle for Anthropic models that can reason
304	cfg := config.Get()
305	if agentCfg, ok := cfg.Agents["coder"]; ok {
306		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
307		model := cfg.GetModelByType(agentCfg.Model)
308		if providerCfg != nil && model != nil &&
309			providerCfg.Type == catwalk.TypeAnthropic && model.CanReason {
310			selectedModel := cfg.Models[agentCfg.Model]
311			status := "Enable"
312			if selectedModel.Think {
313				status = "Disable"
314			}
315			commands = append(commands, Command{
316				ID:          "toggle_thinking",
317				Title:       status + " Thinking Mode",
318				Description: "Toggle model thinking for reasoning-capable models",
319				Handler: func(cmd Command) tea.Cmd {
320					return util.CmdHandler(ToggleThinkingMsg{})
321				},
322			})
323		}
324	}
325	// Only show toggle compact mode command if window width is larger than compact breakpoint (90)
326	if c.wWidth > 120 && c.sessionID != "" {
327		commands = append(commands, Command{
328			ID:          "toggle_sidebar",
329			Title:       "Toggle Sidebar",
330			Description: "Toggle between compact and normal layout",
331			Handler: func(cmd Command) tea.Cmd {
332				return util.CmdHandler(ToggleCompactModeMsg{})
333			},
334		})
335	}
336	if c.sessionID != "" {
337		agentCfg := config.Get().Agents["coder"]
338		model := config.Get().GetModelByType(agentCfg.Model)
339		if model.SupportsImages {
340			commands = append(commands, Command{
341				ID:          "file_picker",
342				Title:       "Open File Picker",
343				Shortcut:    "ctrl+f",
344				Description: "Open file picker",
345				Handler: func(cmd Command) tea.Cmd {
346					return util.CmdHandler(OpenFilePickerMsg{})
347				},
348			})
349		}
350	}
351
352	// Add external editor command if $EDITOR is available
353	if os.Getenv("EDITOR") != "" {
354		commands = append(commands, Command{
355			ID:          "open_external_editor",
356			Title:       "Open External Editor",
357			Shortcut:    "ctrl+o",
358			Description: "Open external editor to compose message",
359			Handler: func(cmd Command) tea.Cmd {
360				return util.CmdHandler(OpenExternalEditorMsg{})
361			},
362		})
363	}
364
365	return append(commands, []Command{
366		{
367			ID:          "toggle_yolo",
368			Title:       "Toggle Yolo Mode",
369			Description: "Toggle yolo mode",
370			Handler: func(cmd Command) tea.Cmd {
371				return util.CmdHandler(ToggleYoloModeMsg{})
372			},
373		},
374		{
375			ID:          "toggle_help",
376			Title:       "Toggle Help",
377			Shortcut:    "ctrl+g",
378			Description: "Toggle help",
379			Handler: func(cmd Command) tea.Cmd {
380				return util.CmdHandler(ToggleHelpMsg{})
381			},
382		},
383		{
384			ID:          "init",
385			Title:       "Initialize Project",
386			Description: "Create/Update the CRUSH.md memory file",
387			Handler: func(cmd Command) tea.Cmd {
388				return util.CmdHandler(chat.SendMsg{
389					Text: prompt.Initialize(),
390				})
391			},
392		},
393		{
394			ID:          "quit",
395			Title:       "Quit",
396			Description: "Quit",
397			Shortcut:    "ctrl+c",
398			Handler: func(cmd Command) tea.Cmd {
399				return util.CmdHandler(QuitMsg{})
400			},
401		},
402	}...)
403}
404
405func (c *commandDialogCmp) ID() dialogs.DialogID {
406	return CommandsDialogID
407}