340 lines
9.2 KiB
Go
340 lines
9.2 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"ai-agent/internal/command"
|
|
"ai-agent/internal/config"
|
|
"ai-agent/internal/mcp"
|
|
)
|
|
|
|
type Completion struct {
|
|
Label string
|
|
Insert string
|
|
Category string
|
|
Description string
|
|
Index int
|
|
}
|
|
|
|
type Completer struct {
|
|
commands []*command.Command
|
|
models []string
|
|
skills []string
|
|
agents []string
|
|
workDir string
|
|
registry *mcp.Registry
|
|
ignorePatterns *config.IgnorePatterns
|
|
}
|
|
|
|
func NewCompleter(cmdReg *command.Registry, models, skills, agents []string, registry *mcp.Registry) *Completer {
|
|
workDir, _ := os.Getwd()
|
|
return &Completer{
|
|
commands: cmdReg.All(),
|
|
models: models,
|
|
skills: skills,
|
|
agents: agents,
|
|
workDir: workDir,
|
|
registry: registry,
|
|
}
|
|
}
|
|
|
|
func (c *Completer) Complete(input string) []Completion {
|
|
var completions []Completion
|
|
|
|
if strings.HasPrefix(input, "/") {
|
|
completions = c.completeCommand(input)
|
|
} else if strings.HasPrefix(input, "@") {
|
|
completions = c.completeAgentOrFile(input)
|
|
} else if strings.HasPrefix(input, "#") {
|
|
completions = c.completeSkill(input)
|
|
}
|
|
|
|
return completions
|
|
}
|
|
|
|
func (c *Completer) completeCommand(input string) []Completion {
|
|
var completions []Completion
|
|
input = strings.TrimPrefix(input, "/")
|
|
|
|
for _, cmd := range c.commands {
|
|
if strings.HasPrefix(cmd.Name, input) {
|
|
comp := Completion{
|
|
Label: "/" + cmd.Name,
|
|
Insert: "/" + cmd.Name + " ",
|
|
Category: "command",
|
|
}
|
|
if cmd.Usage != "" {
|
|
parts := strings.Fields(cmd.Usage)
|
|
if len(parts) > 1 {
|
|
comp.Label = "/" + cmd.Name + " " + parts[1]
|
|
}
|
|
}
|
|
completions = append(completions, comp)
|
|
}
|
|
|
|
for _, alias := range cmd.Aliases {
|
|
if strings.HasPrefix(alias, input) {
|
|
completions = append(completions, Completion{
|
|
Label: "/" + alias,
|
|
Insert: "/" + alias + " ",
|
|
Category: "command",
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return completions
|
|
}
|
|
|
|
func (c *Completer) completeAgentOrFile(input string) []Completion {
|
|
var completions []Completion
|
|
input = strings.TrimPrefix(input, "@")
|
|
|
|
// Always show agents first
|
|
for _, agent := range c.agents {
|
|
if strings.HasPrefix(agent, input) {
|
|
completions = append(completions, Completion{
|
|
Label: "@" + agent,
|
|
Insert: "@" + agent + " ",
|
|
Category: "agent",
|
|
})
|
|
}
|
|
}
|
|
|
|
// Always append file results (not just when no agents match)
|
|
completions = append(completions, c.completeFile(input)...)
|
|
|
|
return completions
|
|
}
|
|
|
|
func (c *Completer) completeFile(input string) []Completion {
|
|
var completions []Completion
|
|
|
|
// Determine the directory to list
|
|
dir := c.workDir
|
|
if strings.Contains(input, "/") {
|
|
// User is typing a path
|
|
lastSlash := strings.LastIndex(input, "/")
|
|
dirPart := input[:lastSlash]
|
|
if !strings.HasPrefix(dirPart, "/") {
|
|
dirPart = filepath.Join(c.workDir, dirPart)
|
|
}
|
|
if info, err := os.Stat(dirPart); err == nil && info.IsDir() {
|
|
dir = dirPart
|
|
}
|
|
}
|
|
|
|
// Read directory entries
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return completions
|
|
}
|
|
|
|
prefix := input
|
|
if strings.Contains(input, "/") {
|
|
prefix = input[strings.LastIndex(input, "/")+1:]
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
// Skip hidden files unless user explicitly types .
|
|
if strings.HasPrefix(name, ".") && !strings.HasPrefix(prefix, ".") {
|
|
continue
|
|
}
|
|
// Skip entries matching ignore patterns.
|
|
if c.ignorePatterns.Match(name) {
|
|
continue
|
|
}
|
|
|
|
if strings.HasPrefix(name, prefix) {
|
|
isDir := entry.IsDir()
|
|
displayName := name
|
|
insertName := name
|
|
|
|
if isDir {
|
|
displayName += "/"
|
|
insertName += "/"
|
|
}
|
|
|
|
// Build full path relative to input
|
|
if strings.Contains(input, "/") {
|
|
dirPath := input[:strings.LastIndex(input, "/")+1]
|
|
displayName = dirPath + displayName
|
|
insertName = dirPath + insertName
|
|
} else if dir != c.workDir {
|
|
relPath, _ := filepath.Rel(c.workDir, dir)
|
|
if relPath != "." {
|
|
displayName = relPath + "/" + name
|
|
if isDir {
|
|
displayName += "/"
|
|
} else {
|
|
insertName = relPath + "/" + insertName
|
|
}
|
|
}
|
|
}
|
|
|
|
category := "file"
|
|
if isDir {
|
|
category = "folder"
|
|
}
|
|
|
|
completions = append(completions, Completion{
|
|
Label: "@" + displayName,
|
|
Insert: "@" + insertName + " ",
|
|
Category: category,
|
|
})
|
|
}
|
|
}
|
|
|
|
return completions
|
|
}
|
|
|
|
// CompleteFilePath lists directory contents at a given relative path.
|
|
// Used for folder drill-down in the completion modal.
|
|
func (c *Completer) CompleteFilePath(relPath string) []Completion {
|
|
var completions []Completion
|
|
|
|
dir := filepath.Join(c.workDir, relPath)
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return completions
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if strings.HasPrefix(name, ".") {
|
|
continue
|
|
}
|
|
// Skip entries matching ignore patterns.
|
|
if c.ignorePatterns.Match(name) {
|
|
continue
|
|
}
|
|
|
|
isDir := entry.IsDir()
|
|
displayName := name
|
|
insertPath := relPath
|
|
if insertPath != "" && !strings.HasSuffix(insertPath, "/") {
|
|
insertPath += "/"
|
|
}
|
|
insertPath += name
|
|
|
|
if isDir {
|
|
displayName += "/"
|
|
}
|
|
|
|
category := "file"
|
|
if isDir {
|
|
category = "folder"
|
|
}
|
|
|
|
completions = append(completions, Completion{
|
|
Label: displayName,
|
|
Insert: "@" + insertPath + " ",
|
|
Category: category,
|
|
})
|
|
}
|
|
|
|
return completions
|
|
}
|
|
|
|
func (c *Completer) completeSkill(input string) []Completion {
|
|
var completions []Completion
|
|
input = strings.TrimPrefix(input, "#")
|
|
|
|
for _, skill := range c.skills {
|
|
if strings.HasPrefix(skill, input) {
|
|
completions = append(completions, Completion{
|
|
Label: "#" + skill,
|
|
Insert: "#" + skill + " ",
|
|
Category: "skill",
|
|
})
|
|
}
|
|
}
|
|
|
|
return completions
|
|
}
|
|
|
|
// FilterCompletions filters completions by case-insensitive substring match on Label.
|
|
func FilterCompletions(items []Completion, query string) []Completion {
|
|
if query == "" {
|
|
return items
|
|
}
|
|
q := strings.ToLower(query)
|
|
var filtered []Completion
|
|
for _, item := range items {
|
|
if strings.Contains(strings.ToLower(item.Label), q) {
|
|
filtered = append(filtered, item)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// SearchFiles performs an async vecgrep search via the MCP registry.
|
|
func (c *Completer) SearchFiles(ctx context.Context, query string) []Completion {
|
|
if c.registry == nil || query == "" {
|
|
return nil
|
|
}
|
|
|
|
result, err := c.registry.CallTool(ctx, "vecgrep_search", map[string]any{
|
|
"query": query,
|
|
"limit": 10,
|
|
})
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
var results []Completion
|
|
// Parse the result content as JSON array of file paths or objects
|
|
var searchResults []struct {
|
|
Path string `json:"path"`
|
|
Score float64 `json:"score"`
|
|
}
|
|
if err := json.Unmarshal([]byte(result.Content), &searchResults); err != nil {
|
|
// Try as simple string lines
|
|
for _, line := range strings.Split(result.Content, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
results = append(results, Completion{
|
|
Label: "@" + line,
|
|
Insert: "@" + line + " ",
|
|
Category: "search_result",
|
|
Description: "vecgrep match",
|
|
})
|
|
}
|
|
return results
|
|
}
|
|
|
|
for _, sr := range searchResults {
|
|
results = append(results, Completion{
|
|
Label: "@" + sr.Path,
|
|
Insert: "@" + sr.Path + " ",
|
|
Category: "search_result",
|
|
Description: "vecgrep match",
|
|
})
|
|
}
|
|
return results
|
|
}
|
|
|
|
func (c *Completer) UpdateModels(models []string) {
|
|
c.models = models
|
|
}
|
|
|
|
func (c *Completer) UpdateSkills(skills []string) {
|
|
c.skills = skills
|
|
}
|
|
|
|
func (c *Completer) UpdateAgents(agents []string) {
|
|
c.agents = agents
|
|
}
|
|
|
|
// SetIgnorePatterns sets the ignore patterns used to filter file completions.
|
|
func (c *Completer) SetIgnorePatterns(patterns *config.IgnorePatterns) {
|
|
c.ignorePatterns = patterns
|
|
}
|