Detailed changes
@@ -19,6 +19,7 @@ OpenCode is a Go-based CLI application that brings AI assistance to your termina
- **LSP Integration**: Language Server Protocol support for code intelligence
- **File Change Tracking**: Track and visualize file changes during sessions
- **External Editor Support**: Open your preferred editor for composing messages
+- **Named Arguments for Custom Commands**: Create powerful custom commands with multiple named placeholders
## Installation
@@ -375,13 +376,22 @@ 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:
+OpenCode supports named arguments in custom commands using placeholders in the format `$NAME` (where NAME consists of uppercase letters, numbers, and underscores, and must start with a letter).
+
+For example:
```markdown
-RUN git show $ARGUMENTS
+# Fetch Context for Issue $ISSUE_NUMBER
+
+RUN gh issue view $ISSUE_NUMBER --json title,body,comments
+RUN git grep --author="$AUTHOR_NAME" -n .
+RUN grep -R "$SEARCH_PATTERN" $DIRECTORY
```
-When you run this command, OpenCode will prompt you to enter the text that should replace `$ARGUMENTS`.
+When you run a command with arguments, OpenCode will prompt you to enter values for each unique placeholder. Named arguments provide several benefits:
+- Clear identification of what each argument represents
+- Ability to use the same argument multiple times
+- Better organization for commands with multiple inputs
### Organizing Commands
@@ -1,6 +1,7 @@
package dialog
import (
+ "fmt"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
@@ -11,35 +12,6 @@ import (
"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
@@ -64,77 +36,204 @@ func (k argumentsDialogKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{k.ShortHelp()}
}
+// ShowMultiArgumentsDialogMsg is a message that is sent to show the multi-arguments dialog.
+type ShowMultiArgumentsDialogMsg struct {
+ CommandID string
+ Content string
+ ArgNames []string
+}
+
+// CloseMultiArgumentsDialogMsg is a message that is sent when the multi-arguments dialog is closed.
+type CloseMultiArgumentsDialogMsg struct {
+ Submit bool
+ CommandID string
+ Content string
+ Args map[string]string
+}
+
+// MultiArgumentsDialogCmp is a component that asks the user for multiple command arguments.
+type MultiArgumentsDialogCmp struct {
+ width, height int
+ inputs []textinput.Model
+ focusIndex int
+ keys argumentsDialogKeyMap
+ commandID string
+ content string
+ argNames []string
+}
+
+// NewMultiArgumentsDialogCmp creates a new MultiArgumentsDialogCmp.
+func NewMultiArgumentsDialogCmp(commandID, content string, argNames []string) MultiArgumentsDialogCmp {
+ t := theme.CurrentTheme()
+ inputs := make([]textinput.Model, len(argNames))
+
+ for i, name := range argNames {
+ ti := textinput.New()
+ ti.Placeholder = fmt.Sprintf("Enter value for %s...", name)
+ 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())
+
+ // Only focus the first input initially
+ if i == 0 {
+ ti.Focus()
+ ti.PromptStyle = ti.PromptStyle.Foreground(t.Primary())
+ ti.TextStyle = ti.TextStyle.Foreground(t.Primary())
+ } else {
+ ti.Blur()
+ }
+
+ inputs[i] = ti
+ }
+
+ return MultiArgumentsDialogCmp{
+ inputs: inputs,
+ keys: argumentsDialogKeyMap{},
+ commandID: commandID,
+ content: content,
+ argNames: argNames,
+ focusIndex: 0,
+ }
+}
+
// Init implements tea.Model.
-func (m ArgumentsDialogCmp) Init() tea.Cmd {
- return tea.Batch(
- textinput.Blink,
- m.textInput.Focus(),
- )
+func (m MultiArgumentsDialogCmp) Init() tea.Cmd {
+ // Make sure only the first input is focused
+ for i := range m.inputs {
+ if i == 0 {
+ m.inputs[i].Focus()
+ } else {
+ m.inputs[i].Blur()
+ }
+ }
+
+ return textinput.Blink
}
// Update implements tea.Model.
-func (m ArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmd tea.Cmd
+func (m MultiArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
+ t := theme.CurrentTheme()
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,
+ return m, util.CmdHandler(CloseMultiArgumentsDialogMsg{
+ Submit: false,
CommandID: m.commandID,
Content: m.content,
- Arguments: m.textInput.Value(),
+ Args: nil,
})
+ case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
+ // If we're on the last input, submit the form
+ if m.focusIndex == len(m.inputs)-1 {
+ args := make(map[string]string)
+ for i, name := range m.argNames {
+ args[name] = m.inputs[i].Value()
+ }
+ return m, util.CmdHandler(CloseMultiArgumentsDialogMsg{
+ Submit: true,
+ CommandID: m.commandID,
+ Content: m.content,
+ Args: args,
+ })
+ }
+ // Otherwise, move to the next input
+ m.inputs[m.focusIndex].Blur()
+ m.focusIndex++
+ m.inputs[m.focusIndex].Focus()
+ m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
+ m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
+ case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
+ // Move to the next input
+ m.inputs[m.focusIndex].Blur()
+ m.focusIndex = (m.focusIndex + 1) % len(m.inputs)
+ m.inputs[m.focusIndex].Focus()
+ m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
+ m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
+ case key.Matches(msg, key.NewBinding(key.WithKeys("shift+tab"))):
+ // Move to the previous input
+ m.inputs[m.focusIndex].Blur()
+ m.focusIndex = (m.focusIndex - 1 + len(m.inputs)) % len(m.inputs)
+ m.inputs[m.focusIndex].Focus()
+ m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
+ m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
}
- m.textInput, cmd = m.textInput.Update(msg)
+ // Update the focused input
+ var cmd tea.Cmd
+ m.inputs[m.focusIndex], cmd = m.inputs[m.focusIndex].Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
// View implements tea.Model.
-func (m ArgumentsDialogCmp) View() string {
+func (m MultiArgumentsDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
// Calculate width needed for content
maxWidth := 60 // Width for explanation text
- title := baseStyle.
+ title := lipgloss.NewStyle().
Foreground(t.Primary()).
Bold(true).
Width(maxWidth).
Padding(0, 1).
+ Background(t.Background()).
Render("Command Arguments")
- explanation := baseStyle.
+ explanation := lipgloss.NewStyle().
Foreground(t.Text()).
Width(maxWidth).
Padding(0, 1).
- Render("This command requires arguments. Please enter the text to replace $ARGUMENTS with:")
+ Background(t.Background()).
+ Render("This command requires multiple arguments. Please enter values for each:")
- inputField := baseStyle.
- Foreground(t.Text()).
- Width(maxWidth).
- Padding(1, 1).
- Render(m.textInput.View())
+ // Create input fields for each argument
+ inputFields := make([]string, len(m.inputs))
+ for i, input := range m.inputs {
+ // Highlight the label of the focused input
+ labelStyle := lipgloss.NewStyle().
+ Width(maxWidth).
+ Padding(1, 1, 0, 1).
+ Background(t.Background())
+
+ if i == m.focusIndex {
+ labelStyle = labelStyle.Foreground(t.Primary()).Bold(true)
+ } else {
+ labelStyle = labelStyle.Foreground(t.TextMuted())
+ }
+
+ label := labelStyle.Render(m.argNames[i] + ":")
+
+ field := lipgloss.NewStyle().
+ Foreground(t.Text()).
+ Width(maxWidth).
+ Padding(0, 1).
+ Background(t.Background()).
+ Render(input.View())
+
+ inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field)
+ }
maxWidth = min(maxWidth, m.width-10)
+ // Join all elements vertically
+ elements := []string{title, explanation}
+ elements = append(elements, inputFields...)
+
content := lipgloss.JoinVertical(
lipgloss.Left,
- title,
- explanation,
- inputField,
+ elements...,
)
return baseStyle.Padding(1, 2).
@@ -147,27 +246,12 @@ func (m ArgumentsDialogCmp) View() string {
}
// SetSize sets the size of the component.
-func (m *ArgumentsDialogCmp) SetSize(width, height int) {
+func (m *MultiArgumentsDialogCmp) SetSize(width, height int) {
m.width = width
m.height = height
}
// Bindings implements layout.Bindings.
-func (m ArgumentsDialogCmp) Bindings() []key.Binding {
+func (m MultiArgumentsDialogCmp) 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
-}
-
+}
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "regexp"
"strings"
tea "github.com/charmbracelet/bubbletea"
@@ -17,6 +18,9 @@ const (
ProjectCommandPrefix = "project:"
)
+// namedArgPattern is a regex pattern to find named arguments in the format $NAME
+var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
+
// LoadCustomCommands loads custom commands from both XDG_CONFIG_HOME and project data directory
func LoadCustomCommands() ([]Command, error) {
cfg := config.Get()
@@ -133,18 +137,33 @@ func loadCommandsFromDir(commandsDir string, prefix string) ([]Command, error) {
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{
+ // Check for named arguments
+ matches := namedArgPattern.FindAllStringSubmatch(commandContent, -1)
+ if len(matches) > 0 {
+ // Extract unique argument names
+ argNames := make([]string, 0)
+ argMap := make(map[string]bool)
+
+ for _, match := range matches {
+ argName := match[1] // Group 1 is the name without $
+ if !argMap[argName] {
+ argMap[argName] = true
+ argNames = append(argNames, argName)
+ }
+ }
+
+ // Show multi-arguments dialog for all named arguments
+ return util.CmdHandler(ShowMultiArgumentsDialogMsg{
CommandID: cmd.ID,
Content: commandContent,
+ ArgNames: argNames,
})
}
// No arguments needed, run command directly
return util.CmdHandler(CommandRunCustomMsg{
Content: commandContent,
+ Args: nil, // No arguments
})
},
}
@@ -163,4 +182,5 @@ func loadCommandsFromDir(commandsDir string, prefix string) ([]Command, error) {
// CommandRunCustomMsg is sent when a custom command is executed
type CommandRunCustomMsg struct {
Content string
+ Args map[string]string // Map of argument names to values
}
@@ -0,0 +1,106 @@
+package dialog
+
+import (
+ "testing"
+ "regexp"
+)
+
+func TestNamedArgPattern(t *testing.T) {
+ testCases := []struct {
+ input string
+ expected []string
+ }{
+ {
+ input: "This is a test with $ARGUMENTS placeholder",
+ expected: []string{"ARGUMENTS"},
+ },
+ {
+ input: "This is a test with $FOO and $BAR placeholders",
+ expected: []string{"FOO", "BAR"},
+ },
+ {
+ input: "This is a test with $FOO_BAR and $BAZ123 placeholders",
+ expected: []string{"FOO_BAR", "BAZ123"},
+ },
+ {
+ input: "This is a test with no placeholders",
+ expected: []string{},
+ },
+ {
+ input: "This is a test with $FOO appearing twice: $FOO",
+ expected: []string{"FOO"},
+ },
+ {
+ input: "This is a test with $1INVALID placeholder",
+ expected: []string{},
+ },
+ }
+
+ for _, tc := range testCases {
+ matches := namedArgPattern.FindAllStringSubmatch(tc.input, -1)
+
+ // Extract unique argument names
+ argNames := make([]string, 0)
+ argMap := make(map[string]bool)
+
+ for _, match := range matches {
+ argName := match[1] // Group 1 is the name without $
+ if !argMap[argName] {
+ argMap[argName] = true
+ argNames = append(argNames, argName)
+ }
+ }
+
+ // Check if we got the expected number of arguments
+ if len(argNames) != len(tc.expected) {
+ t.Errorf("Expected %d arguments, got %d for input: %s", len(tc.expected), len(argNames), tc.input)
+ continue
+ }
+
+ // Check if we got the expected argument names
+ for _, expectedArg := range tc.expected {
+ found := false
+ for _, actualArg := range argNames {
+ if actualArg == expectedArg {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("Expected argument %s not found in %v for input: %s", expectedArg, argNames, tc.input)
+ }
+ }
+ }
+}
+
+func TestRegexPattern(t *testing.T) {
+ pattern := regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
+
+ validMatches := []string{
+ "$FOO",
+ "$BAR",
+ "$FOO_BAR",
+ "$BAZ123",
+ "$ARGUMENTS",
+ }
+
+ invalidMatches := []string{
+ "$foo",
+ "$1BAR",
+ "$_FOO",
+ "FOO",
+ "$",
+ }
+
+ for _, valid := range validMatches {
+ if !pattern.MatchString(valid) {
+ t.Errorf("Expected %s to match, but it didn't", valid)
+ }
+ }
+
+ for _, invalid := range invalidMatches {
+ if pattern.MatchString(invalid) {
+ t.Errorf("Expected %s not to match, but it did", invalid)
+ }
+ }
+}
@@ -2,6 +2,7 @@ package page
import (
"context"
+ "strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
@@ -63,8 +64,19 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if p.app.CoderAgent.IsBusy() {
return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
}
+
+ // Process the command content with arguments if any
+ content := msg.Content
+ if msg.Args != nil {
+ // Replace all named arguments with their values
+ for name, value := range msg.Args {
+ placeholder := "$" + name
+ content = strings.ReplaceAll(content, placeholder, value)
+ }
+ }
+
// Handle custom command execution
- cmd := p.sendMessage(msg.Content, nil)
+ cmd := p.sendMessage(content, nil)
if cmd != nil {
return p, cmd
}
@@ -133,8 +133,8 @@ type appModel struct {
showThemeDialog bool
themeDialog dialog.ThemeDialog
- showArgumentsDialog bool
- argumentsDialog dialog.ArgumentsDialogCmp
+ showMultiArgumentsDialog bool
+ multiArgumentsDialog dialog.MultiArgumentsDialogCmp
isCompacting bool
compactingMessage string
@@ -214,11 +214,11 @@ 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())
+ if a.showMultiArgumentsDialog {
+ a.multiArgumentsDialog.SetSize(msg.Width, msg.Height)
+ args, argsCmd := a.multiArgumentsDialog.Update(msg)
+ a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
+ cmds = append(cmds, argsCmd, a.multiArgumentsDialog.Init())
}
return a, tea.Batch(cmds...)
@@ -438,33 +438,39 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
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.ShowMultiArgumentsDialogMsg:
+ // Show multi-arguments dialog
+ a.multiArgumentsDialog = dialog.NewMultiArgumentsDialogCmp(msg.CommandID, msg.Content, msg.ArgNames)
+ a.showMultiArgumentsDialog = true
+ return a, a.multiArgumentsDialog.Init()
- case dialog.CloseArgumentsDialogMsg:
- // Close arguments dialog
- a.showArgumentsDialog = false
+ case dialog.CloseMultiArgumentsDialogMsg:
+ // Close multi-arguments dialog
+ a.showMultiArgumentsDialog = false
- // If submitted, replace $ARGUMENTS and run the command
+ // If submitted, replace all named arguments and run the command
if msg.Submit {
- // Replace $ARGUMENTS with the provided arguments
- content := strings.ReplaceAll(msg.Content, "$ARGUMENTS", msg.Arguments)
+ content := msg.Content
+
+ // Replace each named argument with its value
+ for name, value := range msg.Args {
+ placeholder := "$" + name
+ content = strings.ReplaceAll(content, placeholder, value)
+ }
// Execute the command with arguments
return a, util.CmdHandler(dialog.CommandRunCustomMsg{
Content: content,
+ Args: msg.Args,
})
}
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)
+ // If multi-arguments dialog is open, let it handle the key press first
+ if a.showMultiArgumentsDialog {
+ args, cmd := a.multiArgumentsDialog.Update(msg)
+ a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
return a, cmd
}
@@ -488,8 +494,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.showModelDialog {
a.showModelDialog = false
}
- if a.showArgumentsDialog {
- a.showArgumentsDialog = false
+ if a.showMultiArgumentsDialog {
+ a.showMultiArgumentsDialog = false
}
return a, nil
case key.Matches(msg, keys.SwitchSession):
@@ -898,8 +904,8 @@ func (a appModel) View() string {
)
}
- if a.showArgumentsDialog {
- overlay := a.argumentsDialog.View()
+ if a.showMultiArgumentsDialog {
+ overlay := a.multiArgumentsDialog.View()
row := lipgloss.Height(appView) / 2
row -= lipgloss.Height(overlay) / 2
col := lipgloss.Width(appView) / 2