on main help sign in
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 <kujtimii.h@gmail.com> * Also search in $HOME/.opencode/commands --------- Co-authored-by: Kujtim Hoxha <kujtimii.h@gmail.com>

commita58e607c5f6dddad1da49a2d23f75e884755c328 authorEd Zynda <ezynda3@users.noreply.github.com> dateFri 9 May 2025, 17:33 (1 year ago) parentcd04c44
5 files changed+483
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
README.md+64
@@ -318,6 +318,70 @@ OpenCode is built with a modular architecture:
318318- **internal/session**: Session management
319319- **internal/lsp**: Language Server Protocol integration
320320
321+## Custom Commands
322+
323+OpenCode supports custom commands that can be created by users to quickly send predefined prompts to the AI assistant.
324+
325+### Creating Custom Commands
326+
327+Custom commands are predefined prompts stored as Markdown files in one of three locations:
328+
329+1. **User Commands** (prefixed with `user:`):
330+ ```
331+ $XDG_CONFIG_HOME/opencode/commands/
332+ ```
333+ (typically `~/.config/opencode/commands/` on Linux/macOS)
334+
335+ or
336+
337+ ```
338+ $HOME/.opencode/commands/
339+ ```
340+
341+2. **Project Commands** (prefixed with `project:`):
342+ ```
343+ <PROJECT DIR>/.opencode/commands/
344+ ```
345+
346+Each `.md` file in these directories becomes a custom command. The file name (without extension) becomes the command ID.
347+
348+For example, creating a file at `~/.config/opencode/commands/prime-context.md` with content:
349+
350+```markdown
351+RUN git ls-files
352+READ README.md
353+```
354+
355+This creates a command called `user:prime-context`.
356+
357+### Command Arguments
358+
359+You can create commands that accept arguments by including the `$ARGUMENTS` placeholder in your command file:
360+
361+```markdown
362+RUN git show $ARGUMENTS
363+```
364+
365+When you run this command, OpenCode will prompt you to enter the text that should replace `$ARGUMENTS`.
366+
367+### Organizing Commands
368+
369+You can organize commands in subdirectories:
370+
371+```
372+~/.config/opencode/commands/git/commit.md
373+```
374+
375+This creates a command with ID `user:git:commit`.
376+
377+### Using Custom Commands
378+
379+1. Press `Ctrl+K` to open the command dialog
380+2. Select your custom command (prefixed with either `user:` or `project:`)
381+3. Press Enter to execute the command
382+
383+The content of the command file will be sent as a message to the AI assistant.
384+
321385## MCP (Model Context Protocol)
322386
323387OpenCode 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.gonew file+173
@@ -0,0 +1,173 @@
1+package dialog
2+
3+import (
4+ "github.com/charmbracelet/bubbles/key"
5+ "github.com/charmbracelet/bubbles/textinput"
6+ tea "github.com/charmbracelet/bubbletea"
7+ "github.com/charmbracelet/lipgloss"
8+
9+ "github.com/opencode-ai/opencode/internal/tui/styles"
10+ "github.com/opencode-ai/opencode/internal/tui/theme"
11+ "github.com/opencode-ai/opencode/internal/tui/util"
12+)
13+
14+// ArgumentsDialogCmp is a component that asks the user for command arguments.
15+type ArgumentsDialogCmp struct {
16+ width, height int
17+ textInput textinput.Model
18+ keys argumentsDialogKeyMap
19+ commandID string
20+ content string
21+}
22+
23+// NewArgumentsDialogCmp creates a new ArgumentsDialogCmp.
24+func NewArgumentsDialogCmp(commandID, content string) ArgumentsDialogCmp {
25+ t := theme.CurrentTheme()
26+ ti := textinput.New()
27+ ti.Placeholder = "Enter arguments..."
28+ ti.Focus()
29+ ti.Width = 40
30+ ti.Prompt = ""
31+ ti.PlaceholderStyle = ti.PlaceholderStyle.Background(t.Background())
32+ ti.PromptStyle = ti.PromptStyle.Background(t.Background())
33+ ti.TextStyle = ti.TextStyle.Background(t.Background())
34+
35+ return ArgumentsDialogCmp{
36+ textInput: ti,
37+ keys: argumentsDialogKeyMap{},
38+ commandID: commandID,
39+ content: content,
40+ }
41+}
42+
43+type argumentsDialogKeyMap struct {
44+ Enter key.Binding
45+ Escape key.Binding
46+}
47+
48+// ShortHelp implements key.Map.
49+func (k argumentsDialogKeyMap) ShortHelp() []key.Binding {
50+ return []key.Binding{
51+ key.NewBinding(
52+ key.WithKeys("enter"),
53+ key.WithHelp("enter", "confirm"),
54+ ),
55+ key.NewBinding(
56+ key.WithKeys("esc"),
57+ key.WithHelp("esc", "cancel"),
58+ ),
59+ }
60+}
61+
62+// FullHelp implements key.Map.
63+func (k argumentsDialogKeyMap) FullHelp() [][]key.Binding {
64+ return [][]key.Binding{k.ShortHelp()}
65+}
66+
67+// Init implements tea.Model.
68+func (m ArgumentsDialogCmp) Init() tea.Cmd {
69+ return tea.Batch(
70+ textinput.Blink,
71+ m.textInput.Focus(),
72+ )
73+}
74+
75+// Update implements tea.Model.
76+func (m ArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
77+ var cmd tea.Cmd
78+ var cmds []tea.Cmd
79+
80+ switch msg := msg.(type) {
81+ case tea.KeyMsg:
82+ switch {
83+ case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
84+ return m, util.CmdHandler(CloseArgumentsDialogMsg{})
85+ case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
86+ return m, util.CmdHandler(CloseArgumentsDialogMsg{
87+ Submit: true,
88+ CommandID: m.commandID,
89+ Content: m.content,
90+ Arguments: m.textInput.Value(),
91+ })
92+ }
93+ case tea.WindowSizeMsg:
94+ m.width = msg.Width
95+ m.height = msg.Height
96+ }
97+
98+ m.textInput, cmd = m.textInput.Update(msg)
99+ cmds = append(cmds, cmd)
100+
101+ return m, tea.Batch(cmds...)
102+}
103+
104+// View implements tea.Model.
105+func (m ArgumentsDialogCmp) View() string {
106+ t := theme.CurrentTheme()
107+ baseStyle := styles.BaseStyle()
108+
109+ // Calculate width needed for content
110+ maxWidth := 60 // Width for explanation text
111+
112+ title := baseStyle.
113+ Foreground(t.Primary()).
114+ Bold(true).
115+ Width(maxWidth).
116+ Padding(0, 1).
117+ Render("Command Arguments")
118+
119+ explanation := baseStyle.
120+ Foreground(t.Text()).
121+ Width(maxWidth).
122+ Padding(0, 1).
123+ Render("This command requires arguments. Please enter the text to replace $ARGUMENTS with:")
124+
125+ inputField := baseStyle.
126+ Foreground(t.Text()).
127+ Width(maxWidth).
128+ Padding(1, 1).
129+ Render(m.textInput.View())
130+
131+ maxWidth = min(maxWidth, m.width-10)
132+
133+ content := lipgloss.JoinVertical(
134+ lipgloss.Left,
135+ title,
136+ explanation,
137+ inputField,
138+ )
139+
140+ return baseStyle.Padding(1, 2).
141+ Border(lipgloss.RoundedBorder()).
142+ BorderBackground(t.Background()).
143+ BorderForeground(t.TextMuted()).
144+ Background(t.Background()).
145+ Width(lipgloss.Width(content) + 4).
146+ Render(content)
147+}
148+
149+// SetSize sets the size of the component.
150+func (m *ArgumentsDialogCmp) SetSize(width, height int) {
151+ m.width = width
152+ m.height = height
153+}
154+
155+// Bindings implements layout.Bindings.
156+func (m ArgumentsDialogCmp) Bindings() []key.Binding {
157+ return m.keys.ShortHelp()
158+}
159+
160+// CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed.
161+type CloseArgumentsDialogMsg struct {
162+ Submit bool
163+ CommandID string
164+ Content string
165+ Arguments string
166+}
167+
168+// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog.
169+type ShowArgumentsDialogMsg struct {
170+ CommandID string
171+ Content string
172+}
173+
internal/tui/components/dialog/custom_commands.gonew file+166
@@ -0,0 +1,166 @@
1+package dialog
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+
9+ tea "github.com/charmbracelet/bubbletea"
10+ "github.com/opencode-ai/opencode/internal/config"
11+ "github.com/opencode-ai/opencode/internal/tui/util"
12+)
13+
14+// Command prefix constants
15+const (
16+ UserCommandPrefix = "user:"
17+ ProjectCommandPrefix = "project:"
18+)
19+
20+// LoadCustomCommands loads custom commands from both XDG_CONFIG_HOME and project data directory
21+func LoadCustomCommands() ([]Command, error) {
22+ cfg := config.Get()
23+ if cfg == nil {
24+ return nil, fmt.Errorf("config not loaded")
25+ }
26+
27+ var commands []Command
28+
29+ // Load user commands from XDG_CONFIG_HOME/opencode/commands
30+ xdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
31+ if xdgConfigHome == "" {
32+ // Default to ~/.config if XDG_CONFIG_HOME is not set
33+ home, err := os.UserHomeDir()
34+ if err == nil {
35+ xdgConfigHome = filepath.Join(home, ".config")
36+ }
37+ }
38+
39+ if xdgConfigHome != "" {
40+ userCommandsDir := filepath.Join(xdgConfigHome, "opencode", "commands")
41+ userCommands, err := loadCommandsFromDir(userCommandsDir, UserCommandPrefix)
42+ if err != nil {
43+ // Log error but continue - we'll still try to load other commands
44+ fmt.Printf("Warning: failed to load user commands from XDG_CONFIG_HOME: %v\n", err)
45+ } else {
46+ commands = append(commands, userCommands...)
47+ }
48+ }
49+
50+ // Load commands from $HOME/.opencode/commands
51+ home, err := os.UserHomeDir()
52+ if err == nil {
53+ homeCommandsDir := filepath.Join(home, ".opencode", "commands")
54+ homeCommands, err := loadCommandsFromDir(homeCommandsDir, UserCommandPrefix)
55+ if err != nil {
56+ // Log error but continue - we'll still try to load other commands
57+ fmt.Printf("Warning: failed to load home commands: %v\n", err)
58+ } else {
59+ commands = append(commands, homeCommands...)
60+ }
61+ }
62+
63+ // Load project commands from data directory
64+ projectCommandsDir := filepath.Join(cfg.Data.Directory, "commands")
65+ projectCommands, err := loadCommandsFromDir(projectCommandsDir, ProjectCommandPrefix)
66+ if err != nil {
67+ // Log error but return what we have so far
68+ fmt.Printf("Warning: failed to load project commands: %v\n", err)
69+ } else {
70+ commands = append(commands, projectCommands...)
71+ }
72+
73+ return commands, nil
74+}
75+
76+// loadCommandsFromDir loads commands from a specific directory with the given prefix
77+func loadCommandsFromDir(commandsDir string, prefix string) ([]Command, error) {
78+ // Check if the commands directory exists
79+ if _, err := os.Stat(commandsDir); os.IsNotExist(err) {
80+ // Create the commands directory if it doesn't exist
81+ if err := os.MkdirAll(commandsDir, 0755); err != nil {
82+ return nil, fmt.Errorf("failed to create commands directory %s: %w", commandsDir, err)
83+ }
84+ // Return empty list since we just created the directory
85+ return []Command{}, nil
86+ }
87+
88+ var commands []Command
89+
90+ // Walk through the commands directory and load all .md files
91+ err := filepath.Walk(commandsDir, func(path string, info os.FileInfo, err error) error {
92+ if err != nil {
93+ return err
94+ }
95+
96+ // Skip directories
97+ if info.IsDir() {
98+ return nil
99+ }
100+
101+ // Only process markdown files
102+ if !strings.HasSuffix(strings.ToLower(info.Name()), ".md") {
103+ return nil
104+ }
105+
106+ // Read the file content
107+ content, err := os.ReadFile(path)
108+ if err != nil {
109+ return fmt.Errorf("failed to read command file %s: %w", path, err)
110+ }
111+
112+ // Get the command ID from the file name without the .md extension
113+ commandID := strings.TrimSuffix(info.Name(), filepath.Ext(info.Name()))
114+
115+ // Get relative path from commands directory
116+ relPath, err := filepath.Rel(commandsDir, path)
117+ if err != nil {
118+ return fmt.Errorf("failed to get relative path for %s: %w", path, err)
119+ }
120+
121+ // Create the command ID from the relative path
122+ // Replace directory separators with colons
123+ commandIDPath := strings.ReplaceAll(filepath.Dir(relPath), string(filepath.Separator), ":")
124+ if commandIDPath != "." {
125+ commandID = commandIDPath + ":" + commandID
126+ }
127+
128+ // Create a command
129+ command := Command{
130+ ID: prefix + commandID,
131+ Title: prefix + commandID,
132+ Description: fmt.Sprintf("Custom command from %s", relPath),
133+ Handler: func(cmd Command) tea.Cmd {
134+ commandContent := string(content)
135+
136+ // Check if the command contains $ARGUMENTS placeholder
137+ if strings.Contains(commandContent, "$ARGUMENTS") {
138+ // Show arguments dialog
139+ return util.CmdHandler(ShowArgumentsDialogMsg{
140+ CommandID: cmd.ID,
141+ Content: commandContent,
142+ })
143+ }
144+
145+ // No arguments needed, run command directly
146+ return util.CmdHandler(CommandRunCustomMsg{
147+ Content: commandContent,
148+ })
149+ },
150+ }
151+
152+ commands = append(commands, command)
153+ return nil
154+ })
155+
156+ if err != nil {
157+ return nil, fmt.Errorf("failed to load custom commands from %s: %w", commandsDir, err)
158+ }
159+
160+ return commands, nil
161+}
162+
163+// CommandRunCustomMsg is sent when a custom command is executed
164+type CommandRunCustomMsg struct {
165+ Content string
166+}
internal/tui/page/chat.go+11
@@ -9,6 +9,7 @@ import (
99 "github.com/opencode-ai/opencode/internal/message"
1010 "github.com/opencode-ai/opencode/internal/session"
1111 "github.com/opencode-ai/opencode/internal/tui/components/chat"
12+ "github.com/opencode-ai/opencode/internal/tui/components/dialog"
1213 "github.com/opencode-ai/opencode/internal/tui/layout"
1314 "github.com/opencode-ai/opencode/internal/tui/util"
1415)
@@ -57,6 +58,16 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
5758 if cmd != nil {
5859 return p, cmd
5960 }
61+ case dialog.CommandRunCustomMsg:
62+ // Check if the agent is busy before executing custom commands
63+ if p.app.CoderAgent.IsBusy() {
64+ return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
65+ }
66+ // Handle custom command execution
67+ cmd := p.sendMessage(msg.Content)
68+ if cmd != nil {
69+ return p, cmd
70+ }
6071 case chat.SessionSelectedMsg:
6172 if p.session.ID == "" {
6273 cmd := p.setSidebar()
internal/tui/tui.go+69
@@ -3,6 +3,7 @@ package tui
33import (
44 "context"
55 "fmt"
6+ "strings"
67
78 "github.com/charmbracelet/bubbles/key"
89 tea "github.com/charmbracelet/bubbletea"
@@ -125,6 +126,9 @@ type appModel struct {
125126
126127 showThemeDialog bool
127128 themeDialog dialog.ThemeDialog
129+
130+ showArgumentsDialog bool
131+ argumentsDialog dialog.ArgumentsDialogCmp
128132}
129133
130134func (a appModel) Init() tea.Cmd {
@@ -199,6 +203,13 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
199203 cmds = append(cmds, filepickerCmd)
200204
201205 a.initDialog.SetSize(msg.Width, msg.Height)
206+
207+ if a.showArgumentsDialog {
208+ a.argumentsDialog.SetSize(msg.Width, msg.Height)
209+ args, argsCmd := a.argumentsDialog.Update(msg)
210+ a.argumentsDialog = args.(dialog.ArgumentsDialogCmp)
211+ cmds = append(cmds, argsCmd, a.argumentsDialog.Init())
212+ }
202213
203214 return a, tea.Batch(cmds...)
204215 // Status
@@ -346,8 +357,37 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
346357 return a, msg.Command.Handler(msg.Command)
347358 }
348359 return a, util.ReportInfo("Command selected: " + msg.Command.Title)
360+
361+ case dialog.ShowArgumentsDialogMsg:
362+ // Show arguments dialog
363+ a.argumentsDialog = dialog.NewArgumentsDialogCmp(msg.CommandID, msg.Content)
364+ a.showArgumentsDialog = true
365+ return a, a.argumentsDialog.Init()
366+
367+ case dialog.CloseArgumentsDialogMsg:
368+ // Close arguments dialog
369+ a.showArgumentsDialog = false
370+
371+ // If submitted, replace $ARGUMENTS and run the command
372+ if msg.Submit {
373+ // Replace $ARGUMENTS with the provided arguments
374+ content := strings.ReplaceAll(msg.Content, "$ARGUMENTS", msg.Arguments)
375+
376+ // Execute the command with arguments
377+ return a, util.CmdHandler(dialog.CommandRunCustomMsg{
378+ Content: content,
379+ })
380+ }
381+ return a, nil
349382
350383 case tea.KeyMsg:
384+ // If arguments dialog is open, let it handle the key press first
385+ if a.showArgumentsDialog {
386+ args, cmd := a.argumentsDialog.Update(msg)
387+ a.argumentsDialog = args.(dialog.ArgumentsDialogCmp)
388+ return a, cmd
389+ }
390+
351391 switch {
352392
353393 case key.Matches(msg, keys.Quit):
@@ -368,6 +408,9 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
368408 if a.showModelDialog {
369409 a.showModelDialog = false
370410 }
411+ if a.showArgumentsDialog {
412+ a.showArgumentsDialog = false
413+ }
371414 return a, nil
372415 case key.Matches(msg, keys.SwitchSession):
373416 if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
@@ -746,6 +789,21 @@ func (a appModel) View() string {
746789 true,
747790 )
748791 }
792+
793+ if a.showArgumentsDialog {
794+ overlay := a.argumentsDialog.View()
795+ row := lipgloss.Height(appView) / 2
796+ row -= lipgloss.Height(overlay) / 2
797+ col := lipgloss.Width(appView) / 2
798+ col -= lipgloss.Width(overlay) / 2
799+ appView = layout.PlaceOverlay(
800+ col,
801+ row,
802+ overlay,
803+ appView,
804+ true,
805+ )
806+ }
749807
750808 return appView
751809}
@@ -792,5 +850,16 @@ If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (
792850 )
793851 },
794852 })
853+
854+ // Load custom commands
855+ customCommands, err := dialog.LoadCustomCommands()
856+ if err != nil {
857+ logging.Warn("Failed to load custom commands", "error", err)
858+ } else {
859+ for _, cmd := range customCommands {
860+ model.RegisterCommand(cmd)
861+ }
862+ }
863+
795864 return model
796865}