Skip to content
This repository was archived by the owner on Sep 18, 2025. It is now read-only.
103 changes: 103 additions & 0 deletions internal/tui/components/dialog/custom_commands.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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"
)

// CustomCommandPrefix is the prefix used for custom commands loaded from files
const CustomCommandPrefix = "user:"

// LoadCustomCommands loads custom commands from the data directory
func LoadCustomCommands() ([]Command, error) {
cfg := config.Get()
if cfg == nil {
return nil, fmt.Errorf("config not loaded")
}

dataDir := cfg.Data.Directory
commandsDir := filepath.Join(dataDir, "commands")

// 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: %w", 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: CustomCommandPrefix + commandID,
Title: CustomCommandPrefix + commandID,
Description: fmt.Sprintf("Custom command from %s", relPath),
Handler: func(cmd Command) tea.Cmd {
return util.CmdHandler(CommandRunCustomMsg{
Content: string(content),
})
},
}

commands = append(commands, command)
return nil
})

if err != nil {
return nil, fmt.Errorf("failed to load custom commands: %w", err)
}

return commands, nil
}

// CommandRunCustomMsg is sent when a custom command is executed
type CommandRunCustomMsg struct {
Content string
}
7 changes: 7 additions & 0 deletions internal/tui/page/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/opencode-ai/opencode/internal/app"
"github.com/opencode-ai/opencode/internal/session"
"github.com/opencode-ai/opencode/internal/tui/components/chat"
"github.com/opencode-ai/opencode/internal/tui/components/dialog"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/util"
)
Expand Down Expand Up @@ -56,6 +57,12 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if cmd != nil {
return p, cmd
}
case dialog.CommandRunCustomMsg:
// Handle custom command execution
cmd := p.sendMessage(msg.Content)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably want to check if the agent is busy here before we send the message.
p.app.CoderAgent.IsBusy()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's busy what should the behavior be?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess just returning a warning should be fine right?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see you already did what I wanted to suggest 😂 awesome.

if cmd != nil {
return p, cmd
}
case chat.SessionSelectedMsg:
if p.session.ID == "" {
cmd := p.setSidebar()
Expand Down
11 changes: 11 additions & 0 deletions internal/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -672,5 +672,16 @@ If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (
)
},
})

// Load custom commands
customCommands, err := dialog.LoadCustomCommands()
if err != nil {
logging.Warn("Failed to load custom commands", "error", err)
} else {
for _, cmd := range customCommands {
model.RegisterCommand(cmd)
}
}

return model
}