diff --git a/README.md b/README.md index 5b4d3a06fc1bdb3beb7a3fffcc780e95196a474b..e4cf0bd8ef5672e814185fd1393d803a9ecee353 100644 --- a/README.md +++ b/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 diff --git a/internal/tui/components/dialog/arguments.go b/internal/tui/components/dialog/arguments.go index 7c9e0f863c6af996c35140225583cf94678b3507..684d8662fcc357adb33f873da9eadab1d3ca6a67 100644 --- a/internal/tui/components/dialog/arguments.go +++ b/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 -} - +} \ No newline at end of file diff --git a/internal/tui/components/dialog/custom_commands.go b/internal/tui/components/dialog/custom_commands.go index affd6a67eaa6a1285abdad177d6b52ae96bd3ec5..049c4735b5bc4302da87d349d885756882e3c14f 100644 --- a/internal/tui/components/dialog/custom_commands.go +++ b/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 } diff --git a/internal/tui/components/dialog/custom_commands_test.go b/internal/tui/components/dialog/custom_commands_test.go new file mode 100644 index 0000000000000000000000000000000000000000..3468ac3b0b2c5acc8999fcf3b444411e7f07ca5c --- /dev/null +++ b/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) + } + } +} \ No newline at end of file diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go index 1ad86207e26cd47573b0803bae9aaee574c8e043..437f4de329476c126c9f7f4aa1dc283ab109081d 100644 --- a/internal/tui/page/chat.go +++ b/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 } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index b6259892dd8fcbf8d43ef06b7ba5075495501a66..700dc04e83d81a92a409cf89d53f839f73b83d22 100644 --- a/internal/tui/tui.go +++ b/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