feat: Support named arguments in custom commands (#158)

Ed Zynda created

* Allow multiple named args

* fix: Fix styling in multi-arguments dialog

* Remove old unused modal

* Focus on only one input at a time

Change summary

README.md                                              |  16 
internal/tui/components/dialog/arguments.go            | 228 ++++++++---
internal/tui/components/dialog/custom_commands.go      |  28 +
internal/tui/components/dialog/custom_commands_test.go | 106 +++++
internal/tui/page/chat.go                              |  14 
internal/tui/tui.go                                    |  58 +-
6 files changed, 344 insertions(+), 106 deletions(-)

Detailed changes

README.md 🔗

@@ -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
 

internal/tui/components/dialog/arguments.go 🔗

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

internal/tui/components/dialog/custom_commands.go 🔗

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

internal/tui/components/dialog/custom_commands_test.go 🔗

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

internal/tui/page/chat.go 🔗

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

internal/tui/tui.go 🔗

@@ -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