Detailed changes
@@ -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.
@@ -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
+}
+
@@ -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
+}
@@ -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()
@@ -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
}