Skip to content

Commit a58e607

Browse files
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]>
1 parent cd04c44 commit a58e607

File tree

5 files changed

+483
-0
lines changed

5 files changed

+483
-0
lines changed

README.md

+64
Original file line numberDiff line numberDiff line change
@@ -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

323387
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.
+173
Original file line numberDiff line numberDiff line change
@@ -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+

0 commit comments

Comments
 (0)