summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorEd Zynda <[email protected]>2025-05-09 17:33:35 +0300
committeradamdottv <[email protected]>2025-05-12 09:58:59 -0500
commit1f8580553c95e46bd478550f0a4fe17a2d039ddc (patch)
tree5c50da0644b2c64ebf467d6d7efca60b972c7ba2
parentf92b2b76dc0836b8ad9f4a47a16941efdb2accf6 (diff)
downloadopencode-1f8580553c95e46bd478550f0a4fe17a2d039ddc.tar.gz
opencode-1f8580553c95e46bd478550f0a4fe17a2d039ddc.zip
feat: custom commands (#133)
* Implement custom commands * Add User: prefix * Reuse var * Check if the agent is busy and if so report a warning * Update README * fix typo * Implement user and project scoped custom commands * Allow for $ARGUMENTS * UI tweaks * Update internal/tui/components/dialog/arguments.go Co-authored-by: Kujtim Hoxha <[email protected]> * Also search in $HOME/.opencode/commands --------- Co-authored-by: Kujtim Hoxha <[email protected]>
-rw-r--r--README.md64
-rw-r--r--internal/tui/components/dialog/arguments.go173
-rw-r--r--internal/tui/components/dialog/custom_commands.go166
-rw-r--r--internal/tui/page/chat.go11
-rw-r--r--internal/tui/tui.go69
5 files changed, 483 insertions, 0 deletions
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:`):
+ ```
+ <PROJECT DIR>/.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
}