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