mirror of
https://github.com/sst/opencode.git
synced 2025-08-04 13:30:52 +00:00
305 lines
8.1 KiB
Go
305 lines
8.1 KiB
Go
package completions
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/lipgloss/v2"
|
|
"github.com/lithammer/fuzzysearch/fuzzy"
|
|
"github.com/sst/opencode/internal/app"
|
|
"github.com/sst/opencode/internal/commands"
|
|
"github.com/sst/opencode/internal/components/dialog"
|
|
"github.com/sst/opencode/internal/styles"
|
|
"github.com/sst/opencode/internal/theme"
|
|
)
|
|
|
|
type CustomCommandFile struct {
|
|
Name string `json:"name"`
|
|
Filename string `json:"filename"`
|
|
Content string `json:"content"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
type CommandCompletionProvider struct {
|
|
app *app.App
|
|
}
|
|
|
|
func NewCommandCompletionProvider(app *app.App) dialog.CompletionProvider {
|
|
return &CommandCompletionProvider{app: app}
|
|
}
|
|
|
|
func (c *CommandCompletionProvider) GetId() string {
|
|
return "commands"
|
|
}
|
|
|
|
func (c *CommandCompletionProvider) GetEmptyMessage() string {
|
|
return "no matching commands"
|
|
}
|
|
|
|
func getCommandCompletionItem(cmd commands.Command, space int, t theme.Theme) dialog.CompletionItemI {
|
|
spacer := strings.Repeat(" ", space)
|
|
title := " /" + cmd.Trigger + styles.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description)
|
|
value := string(cmd.Name)
|
|
return dialog.NewCompletionItem(dialog.CompletionItem{
|
|
Title: title,
|
|
Value: value,
|
|
})
|
|
}
|
|
|
|
func getCustomCommandCompletionItem(cmd CustomCommandFile, space int, t theme.Theme) dialog.CompletionItemI {
|
|
spacer := strings.Repeat(" ", space)
|
|
description := cmd.Description
|
|
if description == "" {
|
|
description = "custom command"
|
|
}
|
|
title := " /" + cmd.Name + styles.NewStyle().Foreground(t.TextMuted()).Render(spacer+description)
|
|
value := "/" + cmd.Name
|
|
return dialog.NewCompletionItem(dialog.CompletionItem{
|
|
Title: title,
|
|
Value: value,
|
|
})
|
|
}
|
|
|
|
// parseMarkdownMetadata extracts description from markdown frontmatter or first heading
|
|
func parseMarkdownMetadata(filePath string) string {
|
|
file, err := os.Open(filePath)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer file.Close()
|
|
|
|
scanner := bufio.NewScanner(file)
|
|
|
|
// Check for YAML frontmatter
|
|
if scanner.Scan() {
|
|
firstLine := strings.TrimSpace(scanner.Text())
|
|
if firstLine == "---" {
|
|
// Parse YAML frontmatter
|
|
descriptionRegex := regexp.MustCompile(`(?i)^description:\s*(.+)$`)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "---" {
|
|
break
|
|
}
|
|
if matches := descriptionRegex.FindStringSubmatch(line); len(matches) > 1 {
|
|
return strings.Trim(matches[1], `"'`)
|
|
}
|
|
}
|
|
} else {
|
|
// Check if first line is a heading and use it as description
|
|
if strings.HasPrefix(firstLine, "#") {
|
|
return strings.TrimSpace(strings.TrimLeft(firstLine, "#"))
|
|
}
|
|
}
|
|
}
|
|
|
|
// If no frontmatter, look for first heading
|
|
file.Seek(0, 0)
|
|
scanner = bufio.NewScanner(file)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if strings.HasPrefix(line, "#") {
|
|
return strings.TrimSpace(strings.TrimLeft(line, "#"))
|
|
}
|
|
// Stop at first non-empty, non-comment line
|
|
if line != "" && !strings.HasPrefix(line, "<!--") {
|
|
break
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func (c *CommandCompletionProvider) getCustomCommands() ([]CustomCommandFile, error) {
|
|
// Try to get commands from server first
|
|
ctx := context.Background()
|
|
serverCommands, err := c.app.CommandsClient.ListCustomCommands(ctx)
|
|
if err == nil {
|
|
// Convert server commands to local format
|
|
var commands []CustomCommandFile
|
|
for _, cmd := range serverCommands {
|
|
description := ""
|
|
if cmd.Description != nil {
|
|
description = *cmd.Description
|
|
}
|
|
commands = append(commands, CustomCommandFile{
|
|
Name: cmd.Name,
|
|
Description: description,
|
|
Filename: filepath.Base(cmd.FilePath),
|
|
Content: cmd.Content,
|
|
})
|
|
}
|
|
return commands, nil
|
|
}
|
|
|
|
// Fallback to local filesystem scanning if server is not available
|
|
var commands []CustomCommandFile
|
|
|
|
// Get global commands from ~/.config/opencode/commands
|
|
globalCommandsDir := filepath.Join(c.app.Info.Path.Config, "commands")
|
|
globalCommands, err := c.scanCommandsDirectory(globalCommandsDir, "")
|
|
if err != nil {
|
|
// Log error but continue with project commands
|
|
globalCommands = []CustomCommandFile{}
|
|
}
|
|
commands = append(commands, globalCommands...)
|
|
|
|
// Get project-level commands from $PWD/.opencode/commands
|
|
cwd, err := os.Getwd()
|
|
if err == nil {
|
|
projectCommandsDir := filepath.Join(cwd, ".opencode", "commands")
|
|
projectCommands, err := c.scanCommandsDirectory(projectCommandsDir, "")
|
|
if err == nil {
|
|
commands = append(commands, projectCommands...)
|
|
}
|
|
}
|
|
|
|
// Sort commands alphabetically
|
|
sort.Slice(commands, func(i, j int) bool {
|
|
return commands[i].Name < commands[j].Name
|
|
})
|
|
|
|
return commands, nil
|
|
}
|
|
|
|
// scanCommandsDirectory recursively scans a directory for markdown command files
|
|
func (c *CommandCompletionProvider) scanCommandsDirectory(baseDir, relativePath string) ([]CustomCommandFile, error) {
|
|
var commands []CustomCommandFile
|
|
|
|
currentDir := filepath.Join(baseDir, relativePath)
|
|
|
|
err := filepath.WalkDir(currentDir, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
// If the commands directory doesn't exist, just return empty list
|
|
if strings.Contains(err.Error(), "no such file or directory") {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
if !strings.HasSuffix(d.Name(), ".md") {
|
|
return nil
|
|
}
|
|
|
|
// Calculate relative path from base commands directory
|
|
relPath, err := filepath.Rel(baseDir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Convert file path to command name with colon notation
|
|
// e.g., "foo/bar.md" -> "foo:bar"
|
|
name := strings.TrimSuffix(relPath, ".md")
|
|
name = strings.ReplaceAll(name, string(filepath.Separator), ":")
|
|
|
|
description := parseMarkdownMetadata(path)
|
|
commands = append(commands, CustomCommandFile{
|
|
Name: name,
|
|
Filename: d.Name(),
|
|
Content: "", // We don't need content for completion
|
|
Description: description,
|
|
})
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return commands, nil
|
|
}
|
|
|
|
func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
|
|
t := theme.CurrentTheme()
|
|
commands := c.app.Commands
|
|
|
|
// Get custom commands
|
|
customCommands, err := c.getCustomCommands()
|
|
if err != nil {
|
|
// Log error but continue with built-in commands
|
|
customCommands = []CustomCommandFile{}
|
|
}
|
|
|
|
// Calculate spacing for alignment
|
|
space := 1
|
|
for _, cmd := range c.app.Commands {
|
|
if lipgloss.Width(cmd.Trigger) > space {
|
|
space = lipgloss.Width(cmd.Trigger)
|
|
}
|
|
}
|
|
for _, cmd := range customCommands {
|
|
if lipgloss.Width(cmd.Name) > space {
|
|
space = lipgloss.Width(cmd.Name)
|
|
}
|
|
}
|
|
space += 2
|
|
|
|
sorted := commands.Sorted()
|
|
if query == "" {
|
|
// If no query, return all commands (built-in + custom)
|
|
items := []dialog.CompletionItemI{}
|
|
|
|
// Add built-in commands
|
|
for _, cmd := range sorted {
|
|
if cmd.Trigger == "" {
|
|
continue
|
|
}
|
|
cmdSpace := space - lipgloss.Width(cmd.Trigger)
|
|
items = append(items, getCommandCompletionItem(cmd, cmdSpace, t))
|
|
}
|
|
|
|
// Add custom commands
|
|
for _, cmd := range customCommands {
|
|
cmdSpace := space - lipgloss.Width(cmd.Name)
|
|
items = append(items, getCustomCommandCompletionItem(cmd, cmdSpace, t))
|
|
}
|
|
|
|
return items, nil
|
|
}
|
|
|
|
// Use fuzzy matching for commands
|
|
var commandNames []string
|
|
commandMap := make(map[string]dialog.CompletionItemI)
|
|
|
|
// Add built-in commands
|
|
for _, cmd := range sorted {
|
|
if cmd.Trigger == "" {
|
|
continue
|
|
}
|
|
cmdSpace := space - lipgloss.Width(cmd.Trigger)
|
|
commandNames = append(commandNames, cmd.Trigger)
|
|
commandMap[cmd.Trigger] = getCommandCompletionItem(cmd, cmdSpace, t)
|
|
}
|
|
|
|
// Add custom commands
|
|
for _, cmd := range customCommands {
|
|
cmdSpace := space - lipgloss.Width(cmd.Name)
|
|
commandNames = append(commandNames, cmd.Name)
|
|
commandMap[cmd.Name] = getCustomCommandCompletionItem(cmd, cmdSpace, t)
|
|
}
|
|
|
|
// Find fuzzy matches
|
|
matches := fuzzy.RankFind(query, commandNames)
|
|
|
|
// Sort by score (best matches first)
|
|
sort.Sort(matches)
|
|
|
|
// Convert matches to completion items
|
|
items := []dialog.CompletionItemI{}
|
|
for _, match := range matches {
|
|
if item, ok := commandMap[match.Target]; ok {
|
|
items = append(items, item)
|
|
}
|
|
}
|
|
return items, nil
|
|
}
|