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