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/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 == catwalk.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}