diff --git a/README.md b/README.md index 407e913e2..b3eb37777 100644 --- a/README.md +++ b/README.md @@ -387,6 +387,70 @@ OpenCode is built with a modular architecture: - **internal/session**: Session management - **internal/lsp**: Language Server Protocol integration +## Custom Commands + +OpenCode supports custom commands that can be created by users to quickly send predefined prompts to the AI assistant. + +### Creating Custom Commands + +Custom commands are predefined prompts stored as Markdown files in one of three locations: + +1. **User Commands** (prefixed with `user:`): + ``` + $XDG_CONFIG_HOME/opencode/commands/ + ``` + (typically `~/.config/opencode/commands/` on Linux/macOS) + + or + + ``` + $HOME/.opencode/commands/ + ``` + +2. **Project Commands** (prefixed with `project:`): + ``` + /.opencode/commands/ + ``` + +Each `.md` file in these directories becomes a custom command. The file name (without extension) becomes the command ID. + +For example, creating a file at `~/.config/opencode/commands/prime-context.md` with content: + +```markdown +RUN git ls-files +READ README.md +``` + +This creates a command called `user:prime-context`. + +### Command Arguments + +You can create commands that accept arguments by including the `$ARGUMENTS` placeholder in your command file: + +```markdown +RUN git show $ARGUMENTS +``` + +When you run this command, OpenCode will prompt you to enter the text that should replace `$ARGUMENTS`. + +### Organizing Commands + +You can organize commands in subdirectories: + +``` +~/.config/opencode/commands/git/commit.md +``` + +This creates a command with ID `user:git:commit`. + +### Using Custom Commands + +1. Press `Ctrl+K` to open the command dialog +2. Select your custom command (prefixed with either `user:` or `project:`) +3. Press Enter to execute the command + +The content of the command file will be sent as a message to the AI assistant. + ## MCP (Model Context Protocol) OpenCode implements the Model Context Protocol (MCP) to extend its capabilities through external tools. MCP provides a standardized way for the AI assistant to interact with external services and tools. diff --git a/internal/tui/components/dialog/arguments.go b/internal/tui/components/dialog/arguments.go new file mode 100644 index 000000000..7c9e0f863 --- /dev/null +++ b/internal/tui/components/dialog/arguments.go @@ -0,0 +1,173 @@ +package dialog + +import ( + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/opencode-ai/opencode/internal/tui/styles" + "github.com/opencode-ai/opencode/internal/tui/theme" + "github.com/opencode-ai/opencode/internal/tui/util" +) + +// ArgumentsDialogCmp is a component that asks the user for command arguments. +type ArgumentsDialogCmp struct { + width, height int + textInput textinput.Model + keys argumentsDialogKeyMap + commandID string + content string +} + +// NewArgumentsDialogCmp creates a new ArgumentsDialogCmp. +func NewArgumentsDialogCmp(commandID, content string) ArgumentsDialogCmp { + t := theme.CurrentTheme() + ti := textinput.New() + ti.Placeholder = "Enter arguments..." + ti.Focus() + ti.Width = 40 + ti.Prompt = "" + ti.PlaceholderStyle = ti.PlaceholderStyle.Background(t.Background()) + ti.PromptStyle = ti.PromptStyle.Background(t.Background()) + ti.TextStyle = ti.TextStyle.Background(t.Background()) + + return ArgumentsDialogCmp{ + textInput: ti, + keys: argumentsDialogKeyMap{}, + commandID: commandID, + content: content, + } +} + +type argumentsDialogKeyMap struct { + Enter key.Binding + Escape key.Binding +} + +// ShortHelp implements key.Map. +func (k argumentsDialogKeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "confirm"), + ), + key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + } +} + +// FullHelp implements key.Map. +func (k argumentsDialogKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{k.ShortHelp()} +} + +// Init implements tea.Model. +func (m ArgumentsDialogCmp) Init() tea.Cmd { + return tea.Batch( + textinput.Blink, + m.textInput.Focus(), + ) +} + +// Update implements tea.Model. +func (m ArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))): + return m, util.CmdHandler(CloseArgumentsDialogMsg{}) + case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): + return m, util.CmdHandler(CloseArgumentsDialogMsg{ + Submit: true, + CommandID: m.commandID, + Content: m.content, + Arguments: m.textInput.Value(), + }) + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + } + + m.textInput, cmd = m.textInput.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +// View implements tea.Model. +func (m ArgumentsDialogCmp) View() string { + t := theme.CurrentTheme() + baseStyle := styles.BaseStyle() + + // Calculate width needed for content + maxWidth := 60 // Width for explanation text + + title := baseStyle. + Foreground(t.Primary()). + Bold(true). + Width(maxWidth). + Padding(0, 1). + Render("Command Arguments") + + explanation := baseStyle. + Foreground(t.Text()). + Width(maxWidth). + Padding(0, 1). + Render("This command requires arguments. Please enter the text to replace $ARGUMENTS with:") + + inputField := baseStyle. + Foreground(t.Text()). + Width(maxWidth). + Padding(1, 1). + Render(m.textInput.View()) + + maxWidth = min(maxWidth, m.width-10) + + content := lipgloss.JoinVertical( + lipgloss.Left, + title, + explanation, + inputField, + ) + + return baseStyle.Padding(1, 2). + Border(lipgloss.RoundedBorder()). + BorderBackground(t.Background()). + BorderForeground(t.TextMuted()). + Background(t.Background()). + Width(lipgloss.Width(content) + 4). + Render(content) +} + +// SetSize sets the size of the component. +func (m *ArgumentsDialogCmp) SetSize(width, height int) { + m.width = width + m.height = height +} + +// Bindings implements layout.Bindings. +func (m ArgumentsDialogCmp) Bindings() []key.Binding { + return m.keys.ShortHelp() +} + +// CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed. +type CloseArgumentsDialogMsg struct { + Submit bool + CommandID string + Content string + Arguments string +} + +// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog. +type ShowArgumentsDialogMsg struct { + CommandID string + Content string +} + diff --git a/internal/tui/components/dialog/custom_commands.go b/internal/tui/components/dialog/custom_commands.go new file mode 100644 index 000000000..affd6a67e --- /dev/null +++ b/internal/tui/components/dialog/custom_commands.go @@ -0,0 +1,166 @@ +package dialog + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/opencode-ai/opencode/internal/config" + "github.com/opencode-ai/opencode/internal/tui/util" +) + +// Command prefix constants +const ( + UserCommandPrefix = "user:" + ProjectCommandPrefix = "project:" +) + +// LoadCustomCommands loads custom commands from both XDG_CONFIG_HOME and project data directory +func LoadCustomCommands() ([]Command, error) { + cfg := config.Get() + if cfg == nil { + return nil, fmt.Errorf("config not loaded") + } + + var commands []Command + + // Load user commands from XDG_CONFIG_HOME/opencode/commands + xdgConfigHome := os.Getenv("XDG_CONFIG_HOME") + if xdgConfigHome == "" { + // Default to ~/.config if XDG_CONFIG_HOME is not set + home, err := os.UserHomeDir() + if err == nil { + xdgConfigHome = filepath.Join(home, ".config") + } + } + + if xdgConfigHome != "" { + userCommandsDir := filepath.Join(xdgConfigHome, "opencode", "commands") + userCommands, err := loadCommandsFromDir(userCommandsDir, UserCommandPrefix) + if err != nil { + // Log error but continue - we'll still try to load other commands + fmt.Printf("Warning: failed to load user commands from XDG_CONFIG_HOME: %v\n", err) + } else { + commands = append(commands, userCommands...) + } + } + + // Load commands from $HOME/.opencode/commands + home, err := os.UserHomeDir() + if err == nil { + homeCommandsDir := filepath.Join(home, ".opencode", "commands") + homeCommands, err := loadCommandsFromDir(homeCommandsDir, UserCommandPrefix) + if err != nil { + // Log error but continue - we'll still try to load other commands + fmt.Printf("Warning: failed to load home commands: %v\n", err) + } else { + commands = append(commands, homeCommands...) + } + } + + // Load project commands from data directory + projectCommandsDir := filepath.Join(cfg.Data.Directory, "commands") + projectCommands, err := loadCommandsFromDir(projectCommandsDir, ProjectCommandPrefix) + if err != nil { + // Log error but return what we have so far + fmt.Printf("Warning: failed to load project commands: %v\n", err) + } else { + commands = append(commands, projectCommands...) + } + + return commands, nil +} + +// loadCommandsFromDir loads commands from a specific directory with the given prefix +func loadCommandsFromDir(commandsDir string, prefix string) ([]Command, error) { + // Check if the commands directory exists + if _, err := os.Stat(commandsDir); os.IsNotExist(err) { + // Create the commands directory if it doesn't exist + if err := os.MkdirAll(commandsDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create commands directory %s: %w", commandsDir, err) + } + // Return empty list since we just created the directory + return []Command{}, nil + } + + var commands []Command + + // Walk through the commands directory and load all .md files + err := filepath.Walk(commandsDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip directories + if info.IsDir() { + return nil + } + + // Only process markdown files + if !strings.HasSuffix(strings.ToLower(info.Name()), ".md") { + return nil + } + + // Read the file content + content, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read command file %s: %w", path, err) + } + + // Get the command ID from the file name without the .md extension + commandID := strings.TrimSuffix(info.Name(), filepath.Ext(info.Name())) + + // Get relative path from commands directory + relPath, err := filepath.Rel(commandsDir, path) + if err != nil { + return fmt.Errorf("failed to get relative path for %s: %w", path, err) + } + + // Create the command ID from the relative path + // Replace directory separators with colons + commandIDPath := strings.ReplaceAll(filepath.Dir(relPath), string(filepath.Separator), ":") + if commandIDPath != "." { + commandID = commandIDPath + ":" + commandID + } + + // Create a command + command := Command{ + ID: prefix + commandID, + Title: prefix + commandID, + Description: fmt.Sprintf("Custom command from %s", relPath), + Handler: func(cmd Command) tea.Cmd { + commandContent := string(content) + + // Check if the command contains $ARGUMENTS placeholder + if strings.Contains(commandContent, "$ARGUMENTS") { + // Show arguments dialog + return util.CmdHandler(ShowArgumentsDialogMsg{ + CommandID: cmd.ID, + Content: commandContent, + }) + } + + // No arguments needed, run command directly + return util.CmdHandler(CommandRunCustomMsg{ + Content: commandContent, + }) + }, + } + + commands = append(commands, command) + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to load custom commands from %s: %w", commandsDir, err) + } + + return commands, nil +} + +// CommandRunCustomMsg is sent when a custom command is executed +type CommandRunCustomMsg struct { + Content string +} diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go index 378575f4d..14c514278 100644 --- a/internal/tui/page/chat.go +++ b/internal/tui/page/chat.go @@ -11,6 +11,7 @@ import ( "github.com/opencode-ai/opencode/internal/session" "github.com/opencode-ai/opencode/internal/status" "github.com/opencode-ai/opencode/internal/tui/components/chat" + "github.com/opencode-ai/opencode/internal/tui/components/dialog" "github.com/opencode-ai/opencode/internal/tui/layout" "github.com/opencode-ai/opencode/internal/tui/util" ) @@ -64,6 +65,16 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd != nil { return p, cmd } + case dialog.CommandRunCustomMsg: + // Check if the agent is busy before executing custom commands + if p.app.CoderAgent.IsBusy() { + return p, util.ReportWarn("Agent is busy, please wait before executing a command...") + } + // Handle custom command execution + cmd := p.sendMessage(msg.Content) + if cmd != nil { + return p, cmd + } case chat.SessionSelectedMsg: if p.session.ID == "" { cmd := p.setSidebar() diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 4e5782353..bb6a2a32f 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -3,6 +3,8 @@ package tui import ( "context" "fmt" + "log/slog" + "strings" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" @@ -126,6 +128,9 @@ type appModel struct { showThemeDialog bool themeDialog dialog.ThemeDialog + + showArgumentsDialog bool + argumentsDialog dialog.ArgumentsDialogCmp } func (a appModel) Init() tea.Cmd { @@ -199,6 +204,13 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.initDialog.SetSize(msg.Width, msg.Height) + if a.showArgumentsDialog { + a.argumentsDialog.SetSize(msg.Width, msg.Height) + args, argsCmd := a.argumentsDialog.Update(msg) + a.argumentsDialog = args.(dialog.ArgumentsDialogCmp) + cmds = append(cmds, argsCmd, a.argumentsDialog.Init()) + } + return a, tea.Batch(cmds...) case pubsub.Event[logging.Log]: @@ -307,7 +319,36 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { status.Info("Command selected: " + msg.Command.Title) return a, nil + case dialog.ShowArgumentsDialogMsg: + // Show arguments dialog + a.argumentsDialog = dialog.NewArgumentsDialogCmp(msg.CommandID, msg.Content) + a.showArgumentsDialog = true + return a, a.argumentsDialog.Init() + + case dialog.CloseArgumentsDialogMsg: + // Close arguments dialog + a.showArgumentsDialog = false + + // If submitted, replace $ARGUMENTS and run the command + if msg.Submit { + // Replace $ARGUMENTS with the provided arguments + content := strings.ReplaceAll(msg.Content, "$ARGUMENTS", msg.Arguments) + + // Execute the command with arguments + return a, util.CmdHandler(dialog.CommandRunCustomMsg{ + Content: content, + }) + } + return a, nil + case tea.KeyMsg: + // If arguments dialog is open, let it handle the key press first + if a.showArgumentsDialog { + args, cmd := a.argumentsDialog.Update(msg) + a.argumentsDialog = args.(dialog.ArgumentsDialogCmp) + return a, cmd + } + switch { case key.Matches(msg, keys.Quit): a.showQuit = !a.showQuit @@ -327,6 +368,9 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.showModelDialog { a.showModelDialog = false } + if a.showArgumentsDialog { + a.showArgumentsDialog = false + } return a, nil case key.Matches(msg, keys.SwitchSession): if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog { @@ -718,6 +762,21 @@ func (a appModel) View() string { ) } + if a.showArgumentsDialog { + overlay := a.argumentsDialog.View() + row := lipgloss.Height(appView) / 2 + row -= lipgloss.Height(overlay) / 2 + col := lipgloss.Width(appView) / 2 + col -= lipgloss.Width(overlay) / 2 + appView = layout.PlaceOverlay( + col, + row, + overlay, + appView, + true, + ) + } + return appView } @@ -781,5 +840,15 @@ If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules ( }, }) + // Load custom commands + customCommands, err := dialog.LoadCustomCommands() + if err != nil { + slog.Warn("Failed to load custom commands", "error", err) + } else { + for _, cmd := range customCommands { + model.RegisterCommand(cmd) + } + } + return model }