diff options
| author | Ed Zynda <[email protected]> | 2025-05-09 17:33:35 +0300 |
|---|---|---|
| committer | adamdottv <[email protected]> | 2025-05-12 09:58:59 -0500 |
| commit | 1f8580553c95e46bd478550f0a4fe17a2d039ddc (patch) | |
| tree | 5c50da0644b2c64ebf467d6d7efca60b972c7ba2 /internal/tui/components | |
| parent | f92b2b76dc0836b8ad9f4a47a16941efdb2accf6 (diff) | |
| download | opencode-1f8580553c95e46bd478550f0a4fe17a2d039ddc.tar.gz opencode-1f8580553c95e46bd478550f0a4fe17a2d039ddc.zip | |
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]>
Diffstat (limited to 'internal/tui/components')
| -rw-r--r-- | internal/tui/components/dialog/arguments.go | 173 | ||||
| -rw-r--r-- | internal/tui/components/dialog/custom_commands.go | 166 |
2 files changed, 339 insertions, 0 deletions
diff --git a/internal/tui/components/dialog/arguments.go b/internal/tui/components/dialog/arguments.go new file mode 100644 index 000000000..7c9e0f863 --- /dev/null +++ b/internal/tui/components/dialog/arguments.go @@ -0,0 +1,173 @@ +package dialog + +import ( + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/opencode-ai/opencode/internal/tui/styles" + "github.com/opencode-ai/opencode/internal/tui/theme" + "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 +} + +// ShortHelp implements key.Map. +func (k argumentsDialogKeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "confirm"), + ), + key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + } +} + +// FullHelp implements key.Map. +func (k argumentsDialogKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{k.ShortHelp()} +} + +// Init implements tea.Model. +func (m ArgumentsDialogCmp) Init() tea.Cmd { + return tea.Batch( + textinput.Blink, + m.textInput.Focus(), + ) +} + +// Update implements tea.Model. +func (m ArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + 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, + CommandID: m.commandID, + Content: m.content, + Arguments: m.textInput.Value(), + }) + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + } + + m.textInput, cmd = m.textInput.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +// View implements tea.Model. +func (m ArgumentsDialogCmp) View() string { + t := theme.CurrentTheme() + baseStyle := styles.BaseStyle() + + // Calculate width needed for content + maxWidth := 60 // Width for explanation text + + title := baseStyle. + Foreground(t.Primary()). + Bold(true). + Width(maxWidth). + Padding(0, 1). + Render("Command Arguments") + + explanation := baseStyle. + Foreground(t.Text()). + Width(maxWidth). + Padding(0, 1). + Render("This command requires arguments. Please enter the text to replace $ARGUMENTS with:") + + inputField := baseStyle. + Foreground(t.Text()). + Width(maxWidth). + Padding(1, 1). + Render(m.textInput.View()) + + maxWidth = min(maxWidth, m.width-10) + + content := lipgloss.JoinVertical( + lipgloss.Left, + title, + explanation, + inputField, + ) + + return baseStyle.Padding(1, 2). + Border(lipgloss.RoundedBorder()). + BorderBackground(t.Background()). + BorderForeground(t.TextMuted()). + Background(t.Background()). + Width(lipgloss.Width(content) + 4). + Render(content) +} + +// SetSize sets the size of the component. +func (m *ArgumentsDialogCmp) SetSize(width, height int) { + m.width = width + m.height = height +} + +// Bindings implements layout.Bindings. +func (m ArgumentsDialogCmp) 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 +} + diff --git a/internal/tui/components/dialog/custom_commands.go b/internal/tui/components/dialog/custom_commands.go new file mode 100644 index 000000000..affd6a67e --- /dev/null +++ b/internal/tui/components/dialog/custom_commands.go @@ -0,0 +1,166 @@ +package dialog + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/opencode-ai/opencode/internal/config" + "github.com/opencode-ai/opencode/internal/tui/util" +) + +// Command prefix constants +const ( + UserCommandPrefix = "user:" + ProjectCommandPrefix = "project:" +) + +// LoadCustomCommands loads custom commands from both XDG_CONFIG_HOME and project data directory +func LoadCustomCommands() ([]Command, error) { + cfg := config.Get() + if cfg == nil { + return nil, fmt.Errorf("config not loaded") + } + + var commands []Command + + // Load user commands from XDG_CONFIG_HOME/opencode/commands + xdgConfigHome := os.Getenv("XDG_CONFIG_HOME") + if xdgConfigHome == "" { + // Default to ~/.config if XDG_CONFIG_HOME is not set + home, err := os.UserHomeDir() + if err == nil { + xdgConfigHome = filepath.Join(home, ".config") + } + } + + if xdgConfigHome != "" { + userCommandsDir := filepath.Join(xdgConfigHome, "opencode", "commands") + userCommands, err := loadCommandsFromDir(userCommandsDir, UserCommandPrefix) + if err != nil { + // Log error but continue - we'll still try to load other commands + fmt.Printf("Warning: failed to load user commands from XDG_CONFIG_HOME: %v\n", err) + } else { + commands = append(commands, userCommands...) + } + } + + // Load commands from $HOME/.opencode/commands + home, err := os.UserHomeDir() + if err == nil { + homeCommandsDir := filepath.Join(home, ".opencode", "commands") + homeCommands, err := loadCommandsFromDir(homeCommandsDir, UserCommandPrefix) + if err != nil { + // Log error but continue - we'll still try to load other commands + fmt.Printf("Warning: failed to load home commands: %v\n", err) + } else { + commands = append(commands, homeCommands...) + } + } + + // Load project commands from data directory + projectCommandsDir := filepath.Join(cfg.Data.Directory, "commands") + projectCommands, err := loadCommandsFromDir(projectCommandsDir, ProjectCommandPrefix) + if err != nil { + // Log error but return what we have so far + fmt.Printf("Warning: failed to load project commands: %v\n", err) + } else { + commands = append(commands, projectCommands...) + } + + return commands, nil +} + +// loadCommandsFromDir loads commands from a specific directory with the given prefix +func loadCommandsFromDir(commandsDir string, prefix string) ([]Command, error) { + // Check if the commands directory exists + if _, err := os.Stat(commandsDir); os.IsNotExist(err) { + // Create the commands directory if it doesn't exist + if err := os.MkdirAll(commandsDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create commands directory %s: %w", commandsDir, err) + } + // Return empty list since we just created the directory + return []Command{}, nil + } + + var commands []Command + + // Walk through the commands directory and load all .md files + err := filepath.Walk(commandsDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip directories + if info.IsDir() { + return nil + } + + // Only process markdown files + if !strings.HasSuffix(strings.ToLower(info.Name()), ".md") { + return nil + } + + // Read the file content + content, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read command file %s: %w", path, err) + } + + // Get the command ID from the file name without the .md extension + commandID := strings.TrimSuffix(info.Name(), filepath.Ext(info.Name())) + + // Get relative path from commands directory + relPath, err := filepath.Rel(commandsDir, path) + if err != nil { + return fmt.Errorf("failed to get relative path for %s: %w", path, err) + } + + // Create the command ID from the relative path + // Replace directory separators with colons + commandIDPath := strings.ReplaceAll(filepath.Dir(relPath), string(filepath.Separator), ":") + if commandIDPath != "." { + commandID = commandIDPath + ":" + commandID + } + + // Create a command + command := Command{ + ID: prefix + commandID, + Title: prefix + commandID, + Description: fmt.Sprintf("Custom command from %s", relPath), + 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{ + CommandID: cmd.ID, + Content: commandContent, + }) + } + + // No arguments needed, run command directly + return util.CmdHandler(CommandRunCustomMsg{ + Content: commandContent, + }) + }, + } + + commands = append(commands, command) + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to load custom commands from %s: %w", commandsDir, err) + } + + return commands, nil +} + +// CommandRunCustomMsg is sent when a custom command is executed +type CommandRunCustomMsg struct { + Content string +} |
