feat: custom commands (#133)

Ed Zynda and Kujtim Hoxha created

* 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 <kujtimii.h@gmail.com>

* Also search in $HOME/.opencode/commands

---------

Co-authored-by: Kujtim Hoxha <kujtimii.h@gmail.com>

Change summary

README.md                                         |  64 ++++++
internal/tui/components/dialog/arguments.go       | 173 +++++++++++++++++
internal/tui/components/dialog/custom_commands.go | 166 ++++++++++++++++
internal/tui/page/chat.go                         |  11 +
internal/tui/tui.go                               |  69 ++++++
5 files changed, 483 insertions(+)

Detailed changes

README.md 🔗

@@ -318,6 +318,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.

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
+}
+

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
+}

internal/tui/page/chat.go 🔗

@@ -9,6 +9,7 @@ import (
 	"github.com/opencode-ai/opencode/internal/message"
 	"github.com/opencode-ai/opencode/internal/session"
 	"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"
 )
@@ -57,6 +58,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()

internal/tui/tui.go 🔗

@@ -3,6 +3,7 @@ package tui
 import (
 	"context"
 	"fmt"
+	"strings"
 
 	"github.com/charmbracelet/bubbles/key"
 	tea "github.com/charmbracelet/bubbletea"
@@ -125,6 +126,9 @@ type appModel struct {
 
 	showThemeDialog bool
 	themeDialog     dialog.ThemeDialog
+	
+	showArgumentsDialog bool
+	argumentsDialog     dialog.ArgumentsDialogCmp
 }
 
 func (a appModel) Init() tea.Cmd {
@@ -199,6 +203,13 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		cmds = append(cmds, filepickerCmd)
 
 		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...)
 	// Status
@@ -346,8 +357,37 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return a, msg.Command.Handler(msg.Command)
 		}
 		return a, util.ReportInfo("Command selected: " + msg.Command.Title)
+		
+	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):
@@ -368,6 +408,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 {
@@ -746,6 +789,21 @@ func (a appModel) View() string {
 			true,
 		)
 	}
+	
+	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
 }
@@ -792,5 +850,16 @@ If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (
 			)
 		},
 	})
+	
+	// Load custom commands
+	customCommands, err := dialog.LoadCustomCommands()
+	if err != nil {
+		logging.Warn("Failed to load custom commands", "error", err)
+	} else {
+		for _, cmd := range customCommands {
+			model.RegisterCommand(cmd)
+		}
+	}
+	
 	return model
 }