From b37ff5fec5a67699465969a3c034210da1d068a7 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 30 May 2025 21:54:16 +0200 Subject: [PATCH] implement completions --- go.mod | 3 +- go.sum | 8 +- internal/completions/files-folders.go | 191 -------- internal/fileutil/fileutil.go | 101 ++-- internal/fileutil/ls.go | 169 +++++++ internal/llm/tools/glob.go | 2 +- internal/llm/tools/ls.go | 98 +--- internal/llm/tools/ls_test.go | 457 ------------------ .../components/chat/{ => editor}/editor.go | 142 ++++-- internal/tui/components/chat/editor/keys.go | 59 +++ .../tui/components/completions/completions.go | 195 ++++++++ internal/tui/components/completions/item.go | 247 ++++++++++ internal/tui/components/completions/keys.go | 53 ++ internal/tui/components/core/list/list.go | 38 +- internal/tui/components/dialog/arguments.go | 252 ---------- internal/tui/components/dialog/commands.go | 182 ------- internal/tui/components/dialog/complete.go | 264 ---------- .../tui/components/dialog/custom_commands.go | 185 ------- .../components/dialog/custom_commands_test.go | 106 ---- .../components/dialogs/commands/commands.go | 9 +- .../tui/components/dialogs/commands/item.go | 145 ------ internal/tui/page/chat.go | 102 +--- internal/tui/tui.go | 69 ++- 23 files changed, 1009 insertions(+), 2068 deletions(-) delete mode 100644 internal/completions/files-folders.go create mode 100644 internal/fileutil/ls.go delete mode 100644 internal/llm/tools/ls_test.go rename internal/tui/components/chat/{ => editor}/editor.go (69%) create mode 100644 internal/tui/components/chat/editor/keys.go create mode 100644 internal/tui/components/completions/completions.go create mode 100644 internal/tui/components/completions/item.go create mode 100644 internal/tui/components/completions/keys.go delete mode 100644 internal/tui/components/dialog/arguments.go delete mode 100644 internal/tui/components/dialog/commands.go delete mode 100644 internal/tui/components/dialog/complete.go delete mode 100644 internal/tui/components/dialog/custom_commands.go delete mode 100644 internal/tui/components/dialog/custom_commands_test.go diff --git a/go.mod b/go.mod index 87928e392256bead51a38f3773fc6728cb2717b3..0fb1b62102f0a7a3ed14652c28c1bf814a480fdf 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/aymanbagabas/go-udiff v0.2.0 github.com/bmatcuk/doublestar/v4 v4.8.1 github.com/catppuccin/go v0.3.0 + github.com/charlievieth/fastwalk v1.0.11 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe @@ -27,6 +28,7 @@ require ( github.com/ncruces/go-sqlite3 v0.25.0 github.com/openai/openai-go v0.1.0-beta.2 github.com/pressly/goose/v3 v3.24.2 + github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/sahilm/fuzzy v0.1.1 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 github.com/spf13/cobra v1.9.1 @@ -81,7 +83,6 @@ require ( github.com/gorilla/websocket v1.5.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect - github.com/lithammer/fuzzysearch v1.1.8 github.com/lucasb-eyer/go-colorful v1.2.0 github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect diff --git a/go.sum b/go.sum index 7acf6b4bada37cbe5776a3c84ee3ff0ddc7e1f3a..c60ac51e573f37283022305dce9e10f9c2f0ed5f 100644 --- a/go.sum +++ b/go.sum @@ -68,6 +68,8 @@ github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8= +github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318 h1:f8Q0ybZGxT+St1JfPM7yoz/XFpbmtodcIehaom/9XT8= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c h1:EoW1x1K2EDKYw1D7raqZqWKnwk21IZVpYqLHQVhz1ZU= @@ -148,8 +150,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= -github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mark3labs/mcp-go v0.17.0 h1:5Ps6T7qXr7De/2QTqs9h6BKeZ/qdeUeGrgM5lPzi930= @@ -197,6 +197,8 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= @@ -224,6 +226,7 @@ github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqj github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -348,6 +351,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8= diff --git a/internal/completions/files-folders.go b/internal/completions/files-folders.go deleted file mode 100644 index af1b5a8742d7deac39a082c7eba4d3ebf9b303b0..0000000000000000000000000000000000000000 --- a/internal/completions/files-folders.go +++ /dev/null @@ -1,191 +0,0 @@ -package completions - -import ( - "bytes" - "fmt" - "os/exec" - "path/filepath" - - "github.com/lithammer/fuzzysearch/fuzzy" - "github.com/opencode-ai/opencode/internal/fileutil" - "github.com/opencode-ai/opencode/internal/logging" - "github.com/opencode-ai/opencode/internal/tui/components/dialog" -) - -type filesAndFoldersContextGroup struct { - prefix string -} - -func (cg *filesAndFoldersContextGroup) GetId() string { - return cg.prefix -} - -func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI { - return dialog.NewCompletionItem(dialog.CompletionItem{ - Title: "Files & Folders", - Value: "files", - }) -} - -func processNullTerminatedOutput(outputBytes []byte) []string { - if len(outputBytes) > 0 && outputBytes[len(outputBytes)-1] == 0 { - outputBytes = outputBytes[:len(outputBytes)-1] - } - - if len(outputBytes) == 0 { - return []string{} - } - - split := bytes.Split(outputBytes, []byte{0}) - matches := make([]string, 0, len(split)) - - for _, p := range split { - if len(p) == 0 { - continue - } - - path := string(p) - path = filepath.Join(".", path) - - if !fileutil.SkipHidden(path) { - matches = append(matches, path) - } - } - - return matches -} - -func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) { - cmdRg := fileutil.GetRgCmd("") // No glob pattern for this use case - cmdFzf := fileutil.GetFzfCmd(query) - - var matches []string - // Case 1: Both rg and fzf available - if cmdRg != nil && cmdFzf != nil { - rgPipe, err := cmdRg.StdoutPipe() - if err != nil { - return nil, fmt.Errorf("failed to get rg stdout pipe: %w", err) - } - defer rgPipe.Close() - - cmdFzf.Stdin = rgPipe - var fzfOut bytes.Buffer - var fzfErr bytes.Buffer - cmdFzf.Stdout = &fzfOut - cmdFzf.Stderr = &fzfErr - - if err := cmdFzf.Start(); err != nil { - return nil, fmt.Errorf("failed to start fzf: %w", err) - } - - errRg := cmdRg.Run() - errFzf := cmdFzf.Wait() - - if errRg != nil { - logging.Warn(fmt.Sprintf("rg command failed during pipe: %v", errRg)) - } - - if errFzf != nil { - if exitErr, ok := errFzf.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { - return []string{}, nil // No matches from fzf - } - return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", errFzf, fzfErr.String()) - } - - matches = processNullTerminatedOutput(fzfOut.Bytes()) - - // Case 2: Only rg available - } else if cmdRg != nil { - logging.Debug("Using Ripgrep with fuzzy match fallback for file completions") - var rgOut bytes.Buffer - var rgErr bytes.Buffer - cmdRg.Stdout = &rgOut - cmdRg.Stderr = &rgErr - - if err := cmdRg.Run(); err != nil { - return nil, fmt.Errorf("rg command failed: %w\nStderr: %s", err, rgErr.String()) - } - - allFiles := processNullTerminatedOutput(rgOut.Bytes()) - matches = fuzzy.Find(query, allFiles) - - // Case 3: Only fzf available - } else if cmdFzf != nil { - logging.Debug("Using FZF with doublestar fallback for file completions") - files, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0) - if err != nil { - return nil, fmt.Errorf("failed to list files for fzf: %w", err) - } - - allFiles := make([]string, 0, len(files)) - for _, file := range files { - if !fileutil.SkipHidden(file) { - allFiles = append(allFiles, file) - } - } - - var fzfIn bytes.Buffer - for _, file := range allFiles { - fzfIn.WriteString(file) - fzfIn.WriteByte(0) - } - - cmdFzf.Stdin = &fzfIn - var fzfOut bytes.Buffer - var fzfErr bytes.Buffer - cmdFzf.Stdout = &fzfOut - cmdFzf.Stderr = &fzfErr - - if err := cmdFzf.Run(); err != nil { - if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { - return []string{}, nil - } - return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", err, fzfErr.String()) - } - - matches = processNullTerminatedOutput(fzfOut.Bytes()) - - // Case 4: Fallback to doublestar with fuzzy match - } else { - logging.Debug("Using doublestar with fuzzy match for file completions") - allFiles, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0) - if err != nil { - return nil, fmt.Errorf("failed to glob files: %w", err) - } - - filteredFiles := make([]string, 0, len(allFiles)) - for _, file := range allFiles { - if !fileutil.SkipHidden(file) { - filteredFiles = append(filteredFiles, file) - } - } - - matches = fuzzy.Find(query, filteredFiles) - } - - return matches, nil -} - -func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) { - matches, err := cg.getFiles(query) - if err != nil { - return nil, err - } - - items := make([]dialog.CompletionItemI, 0, len(matches)) - for _, file := range matches { - item := dialog.NewCompletionItem(dialog.CompletionItem{ - Title: file, - Value: file, - }) - items = append(items, item) - } - - return items, nil -} - -func NewFileAndFolderContextGroup() dialog.CompletionProvider { - return &filesAndFoldersContextGroup{ - prefix: "file", - } -} diff --git a/internal/fileutil/fileutil.go b/internal/fileutil/fileutil.go index 1883f1853db8aa414e1ca0b392c7e7f858d7f068..125979df7b98247dcde89980671fddf851dfb2ef 100644 --- a/internal/fileutil/fileutil.go +++ b/internal/fileutil/fileutil.go @@ -2,7 +2,6 @@ package fileutil import ( "fmt" - "io/fs" "os" "os/exec" "path/filepath" @@ -11,7 +10,9 @@ import ( "time" "github.com/bmatcuk/doublestar/v4" + "github.com/charlievieth/fastwalk" "github.com/opencode-ai/opencode/internal/logging" + ignore "github.com/sabhiram/go-gitignore" ) var ( @@ -53,21 +54,6 @@ func GetRgCmd(globPattern string) *exec.Cmd { return cmd } -func GetFzfCmd(query string) *exec.Cmd { - if fzfPath == "" { - return nil - } - fzfArgs := []string{ - "--filter", - query, - "--read0", - "--print0", - } - cmd := exec.Command(fzfPath, fzfArgs...) - cmd.Dir = "." - return cmd -} - type FileInfo struct { Path string ModTime time.Time @@ -112,37 +98,92 @@ func SkipHidden(path string) bool { return false } -func GlobWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) { - fsys := os.DirFS(searchPath) - relPattern := strings.TrimPrefix(pattern, "/") +// FastGlobWalker provides gitignore-aware file walking with fastwalk +type FastGlobWalker struct { + gitignore *ignore.GitIgnore + rootPath string +} + +func NewFastGlobWalker(searchPath string) *FastGlobWalker { + walker := &FastGlobWalker{ + rootPath: searchPath, + } + + // Load gitignore if it exists + gitignorePath := filepath.Join(searchPath, ".gitignore") + if _, err := os.Stat(gitignorePath); err == nil { + if gi, err := ignore.CompileIgnoreFile(gitignorePath); err == nil { + walker.gitignore = gi + } + } + + return walker +} + +func (w *FastGlobWalker) shouldSkip(path string) bool { + if SkipHidden(path) { + return true + } + + if w.gitignore != nil { + relPath, err := filepath.Rel(w.rootPath, path) + if err == nil && w.gitignore.MatchesPath(relPath) { + return true + } + } + + return false +} + +func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool, error) { + walker := NewFastGlobWalker(searchPath) var matches []FileInfo + conf := fastwalk.Config{ + Follow: true, + // Use forward slashes when running a Windows binary under WSL or MSYS + ToSlash: fastwalk.DefaultToSlash(), + Sort: fastwalk.SortFilesFirst, + } + err := fastwalk.Walk(&conf, searchPath, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil // Skip files we can't access + } - err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error { if d.IsDir() { + if walker.shouldSkip(path) { + return filepath.SkipDir + } return nil } - if SkipHidden(path) { + + if walker.shouldSkip(path) { return nil } - info, err := d.Info() + + // Check if path matches the pattern + relPath, err := filepath.Rel(searchPath, path) if err != nil { + relPath = path + } + + matched, err := doublestar.Match(pattern, relPath) + if err != nil || !matched { return nil } - absPath := path - if !strings.HasPrefix(absPath, searchPath) && searchPath != "." { - absPath = filepath.Join(searchPath, absPath) - } else if !strings.HasPrefix(absPath, "/") && searchPath == "." { - absPath = filepath.Join(searchPath, absPath) // Ensure relative paths are joined correctly + + info, err := d.Info() + if err != nil { + return nil } - matches = append(matches, FileInfo{Path: absPath, ModTime: info.ModTime()}) + matches = append(matches, FileInfo{Path: path, ModTime: info.ModTime()}) if limit > 0 && len(matches) >= limit*2 { - return fs.SkipAll + return filepath.SkipAll } return nil }) if err != nil { - return nil, false, fmt.Errorf("glob walk error: %w", err) + return nil, false, fmt.Errorf("fastwalk error: %w", err) } sort.Slice(matches, func(i, j int) bool { diff --git a/internal/fileutil/ls.go b/internal/fileutil/ls.go new file mode 100644 index 0000000000000000000000000000000000000000..9ea0dfa670388f46ff339f77f03a9dd60897d2b8 --- /dev/null +++ b/internal/fileutil/ls.go @@ -0,0 +1,169 @@ +package fileutil + +import ( + "os" + "path/filepath" + "strings" + + "github.com/charlievieth/fastwalk" + ignore "github.com/sabhiram/go-gitignore" +) + +// CommonIgnorePatterns contains commonly ignored files and directories +var CommonIgnorePatterns = []string{ + // Version control + ".git", + ".svn", + ".hg", + ".bzr", + + // IDE and editor files + ".vscode", + ".idea", + "*.swp", + "*.swo", + "*~", + ".DS_Store", + "Thumbs.db", + + // Build artifacts and dependencies + "node_modules", + "target", + "build", + "dist", + "out", + "bin", + "obj", + "*.o", + "*.so", + "*.dylib", + "*.dll", + "*.exe", + + // Logs and temporary files + "*.log", + "*.tmp", + "*.temp", + ".cache", + ".tmp", + + // Language-specific + "__pycache__", + "*.pyc", + "*.pyo", + ".pytest_cache", + "vendor", + "Cargo.lock", + "package-lock.json", + "yarn.lock", + "pnpm-lock.yaml", + + // OS generated files + ".Trash", + ".Spotlight-V100", + ".fseventsd", + + // OpenCode + ".opencode", +} + +type DirectoryLister struct { + gitignore *ignore.GitIgnore + commonIgnore *ignore.GitIgnore + rootPath string +} + +func NewDirectoryLister(rootPath string) *DirectoryLister { + dl := &DirectoryLister{ + rootPath: rootPath, + } + + // Load gitignore if it exists + gitignorePath := filepath.Join(rootPath, ".gitignore") + if _, err := os.Stat(gitignorePath); err == nil { + if gi, err := ignore.CompileIgnoreFile(gitignorePath); err == nil { + dl.gitignore = gi + } + } + + // Create common ignore patterns + dl.commonIgnore = ignore.CompileIgnoreLines(CommonIgnorePatterns...) + + return dl +} + +func (dl *DirectoryLister) shouldIgnore(path string, ignorePatterns []string) bool { + relPath, err := filepath.Rel(dl.rootPath, path) + if err != nil { + relPath = path + } + + // Check common ignore patterns + if dl.commonIgnore.MatchesPath(relPath) { + return true + } + + // Check gitignore patterns if available + if dl.gitignore != nil && dl.gitignore.MatchesPath(relPath) { + return true + } + + base := filepath.Base(path) + + if base != "." && strings.HasPrefix(base, ".") { + return true + } + + for _, pattern := range ignorePatterns { + matched, err := filepath.Match(pattern, base) + if err == nil && matched { + return true + } + } + return false +} + +// ListDirectory lists files and directories in the specified path, +func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) { + var results []string + truncated := false + dl := NewDirectoryLister(initialPath) + + conf := fastwalk.Config{ + Follow: true, + // Use forward slashes when running a Windows binary under WSL or MSYS + ToSlash: fastwalk.DefaultToSlash(), + Sort: fastwalk.SortDirsFirst, + } + err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil // Skip files we don't have permission to access + } + + if dl.shouldIgnore(path, ignorePatterns) { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + + if path != initialPath { + if d.IsDir() { + path = path + string(filepath.Separator) + } + results = append(results, path) + } + + if limit > 0 && len(results) >= limit { + truncated = true + return filepath.SkipAll + } + + return nil + }) + if err != nil { + return nil, truncated, err + } + + return results, truncated, nil +} diff --git a/internal/llm/tools/glob.go b/internal/llm/tools/glob.go index 9894d9baab1ef778865ea10c0ea04a67848ea6e8..5726c612ef8de79fbf05e227bdedb346b48e7add 100644 --- a/internal/llm/tools/glob.go +++ b/internal/llm/tools/glob.go @@ -137,7 +137,7 @@ func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) { logging.Warn(fmt.Sprintf("Ripgrep execution failed: %v. Falling back to doublestar.", err)) } - return fileutil.GlobWithDoublestar(pattern, searchPath, limit) + return fileutil.GlobWithDoubleStar(pattern, searchPath, limit) } func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) { diff --git a/internal/llm/tools/ls.go b/internal/llm/tools/ls.go index 0febbf8e8f28c3d64c97e755c2bdf8068131c355..383fc50507585382ec2611a03ac0d2c58f4e09b4 100644 --- a/internal/llm/tools/ls.go +++ b/internal/llm/tools/ls.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/opencode-ai/opencode/internal/config" + "github.com/opencode-ai/opencode/internal/fileutil" ) type LSParams struct { @@ -107,7 +108,7 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) { return NewTextErrorResponse(fmt.Sprintf("path does not exist: %s", searchPath)), nil } - files, truncated, err := listDirectory(searchPath, params.Ignore, MaxLSFiles) + files, truncated, err := fileutil.ListDirectory(searchPath, params.Ignore, MaxLSFiles) if err != nil { return ToolResponse{}, fmt.Errorf("error listing directory: %w", err) } @@ -128,101 +129,6 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) { ), nil } -func listDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) { - var results []string - truncated := false - - err := filepath.Walk(initialPath, func(path string, info os.FileInfo, err error) error { - if err != nil { - return nil // Skip files we don't have permission to access - } - - if shouldSkip(path, ignorePatterns) { - if info.IsDir() { - return filepath.SkipDir - } - return nil - } - - if path != initialPath { - if info.IsDir() { - path = path + string(filepath.Separator) - } - results = append(results, path) - } - - if len(results) >= limit { - truncated = true - return filepath.SkipAll - } - - return nil - }) - if err != nil { - return nil, truncated, err - } - - return results, truncated, nil -} - -func shouldSkip(path string, ignorePatterns []string) bool { - base := filepath.Base(path) - - if base != "." && strings.HasPrefix(base, ".") { - return true - } - - commonIgnored := []string{ - "__pycache__", - "node_modules", - "dist", - "build", - "target", - "vendor", - "bin", - "obj", - ".git", - ".idea", - ".vscode", - ".DS_Store", - "*.pyc", - "*.pyo", - "*.pyd", - "*.so", - "*.dll", - "*.exe", - } - - if strings.Contains(path, filepath.Join("__pycache__", "")) { - return true - } - - for _, ignored := range commonIgnored { - if strings.HasSuffix(ignored, "/") { - if strings.Contains(path, filepath.Join(ignored[:len(ignored)-1], "")) { - return true - } - } else if strings.HasPrefix(ignored, "*.") { - if strings.HasSuffix(base, ignored[1:]) { - return true - } - } else { - if base == ignored { - return true - } - } - } - - for _, pattern := range ignorePatterns { - matched, err := filepath.Match(pattern, base) - if err == nil && matched { - return true - } - } - - return false -} - func createFileTree(sortedPaths []string) []*TreeNode { root := []*TreeNode{} pathMap := make(map[string]*TreeNode) diff --git a/internal/llm/tools/ls_test.go b/internal/llm/tools/ls_test.go deleted file mode 100644 index 98c97ed95b5db4bbb0ee5f21ba5ee646a43de889..0000000000000000000000000000000000000000 --- a/internal/llm/tools/ls_test.go +++ /dev/null @@ -1,457 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestLsTool_Info(t *testing.T) { - tool := NewLsTool() - info := tool.Info() - - assert.Equal(t, LSToolName, info.Name) - assert.NotEmpty(t, info.Description) - assert.Contains(t, info.Parameters, "path") - assert.Contains(t, info.Parameters, "ignore") - assert.Contains(t, info.Required, "path") -} - -func TestLsTool_Run(t *testing.T) { - // Create a temporary directory for testing - tempDir, err := os.MkdirTemp("", "ls_tool_test") - require.NoError(t, err) - defer os.RemoveAll(tempDir) - - // Create a test directory structure - testDirs := []string{ - "dir1", - "dir2", - "dir2/subdir1", - "dir2/subdir2", - "dir3", - "dir3/.hidden_dir", - "__pycache__", - } - - testFiles := []string{ - "file1.txt", - "file2.txt", - "dir1/file3.txt", - "dir2/file4.txt", - "dir2/subdir1/file5.txt", - "dir2/subdir2/file6.txt", - "dir3/file7.txt", - "dir3/.hidden_file.txt", - "__pycache__/cache.pyc", - ".hidden_root_file.txt", - } - - // Create directories - for _, dir := range testDirs { - dirPath := filepath.Join(tempDir, dir) - err := os.MkdirAll(dirPath, 0o755) - require.NoError(t, err) - } - - // Create files - for _, file := range testFiles { - filePath := filepath.Join(tempDir, file) - err := os.WriteFile(filePath, []byte("test content"), 0o644) - require.NoError(t, err) - } - - t.Run("lists directory successfully", func(t *testing.T) { - tool := NewLsTool() - params := LSParams{ - Path: tempDir, - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: LSToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - - // Check that visible directories and files are included - assert.Contains(t, response.Content, "dir1") - assert.Contains(t, response.Content, "dir2") - assert.Contains(t, response.Content, "dir3") - assert.Contains(t, response.Content, "file1.txt") - assert.Contains(t, response.Content, "file2.txt") - - // Check that hidden files and directories are not included - assert.NotContains(t, response.Content, ".hidden_dir") - assert.NotContains(t, response.Content, ".hidden_file.txt") - assert.NotContains(t, response.Content, ".hidden_root_file.txt") - - // Check that __pycache__ is not included - assert.NotContains(t, response.Content, "__pycache__") - }) - - t.Run("handles non-existent path", func(t *testing.T) { - tool := NewLsTool() - params := LSParams{ - Path: filepath.Join(tempDir, "non_existent_dir"), - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: LSToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "path does not exist") - }) - - t.Run("handles empty path parameter", func(t *testing.T) { - // For this test, we need to mock the config.WorkingDirectory function - // Since we can't easily do that, we'll just check that the response doesn't contain an error message - - tool := NewLsTool() - params := LSParams{ - Path: "", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: LSToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - - // The response should either contain a valid directory listing or an error - // We'll just check that it's not empty - assert.NotEmpty(t, response.Content) - }) - - t.Run("handles invalid parameters", func(t *testing.T) { - tool := NewLsTool() - call := ToolCall{ - Name: LSToolName, - Input: "invalid json", - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "error parsing parameters") - }) - - t.Run("respects ignore patterns", func(t *testing.T) { - tool := NewLsTool() - params := LSParams{ - Path: tempDir, - Ignore: []string{"file1.txt", "dir1"}, - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: LSToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - - // The output format is a tree, so we need to check for specific patterns - // Check that file1.txt is not directly mentioned - assert.NotContains(t, response.Content, "- file1.txt") - - // Check that dir1/ is not directly mentioned - assert.NotContains(t, response.Content, "- dir1/") - }) - - t.Run("handles relative path", func(t *testing.T) { - // Save original working directory - origWd, err := os.Getwd() - require.NoError(t, err) - defer func() { - os.Chdir(origWd) - }() - - // Change to a directory above the temp directory - parentDir := filepath.Dir(tempDir) - err = os.Chdir(parentDir) - require.NoError(t, err) - - tool := NewLsTool() - params := LSParams{ - Path: filepath.Base(tempDir), - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: LSToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - - // Should list the temp directory contents - assert.Contains(t, response.Content, "dir1") - assert.Contains(t, response.Content, "file1.txt") - }) -} - -func TestShouldSkip(t *testing.T) { - testCases := []struct { - name string - path string - ignorePatterns []string - expected bool - }{ - { - name: "hidden file", - path: "/path/to/.hidden_file", - ignorePatterns: []string{}, - expected: true, - }, - { - name: "hidden directory", - path: "/path/to/.hidden_dir", - ignorePatterns: []string{}, - expected: true, - }, - { - name: "pycache directory", - path: "/path/to/__pycache__/file.pyc", - ignorePatterns: []string{}, - expected: true, - }, - { - name: "node_modules directory", - path: "/path/to/node_modules/package", - ignorePatterns: []string{}, - expected: false, // The shouldSkip function doesn't directly check for node_modules in the path - }, - { - name: "normal file", - path: "/path/to/normal_file.txt", - ignorePatterns: []string{}, - expected: false, - }, - { - name: "normal directory", - path: "/path/to/normal_dir", - ignorePatterns: []string{}, - expected: false, - }, - { - name: "ignored by pattern", - path: "/path/to/ignore_me.txt", - ignorePatterns: []string{"ignore_*.txt"}, - expected: true, - }, - { - name: "not ignored by pattern", - path: "/path/to/keep_me.txt", - ignorePatterns: []string{"ignore_*.txt"}, - expected: false, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - result := shouldSkip(tc.path, tc.ignorePatterns) - assert.Equal(t, tc.expected, result) - }) - } -} - -func TestCreateFileTree(t *testing.T) { - paths := []string{ - "/path/to/file1.txt", - "/path/to/dir1/file2.txt", - "/path/to/dir1/subdir/file3.txt", - "/path/to/dir2/file4.txt", - } - - tree := createFileTree(paths) - - // Check the structure of the tree - assert.Len(t, tree, 1) // Should have one root node - - // Check the root node - rootNode := tree[0] - assert.Equal(t, "path", rootNode.Name) - assert.Equal(t, "directory", rootNode.Type) - assert.Len(t, rootNode.Children, 1) - - // Check the "to" node - toNode := rootNode.Children[0] - assert.Equal(t, "to", toNode.Name) - assert.Equal(t, "directory", toNode.Type) - assert.Len(t, toNode.Children, 3) // file1.txt, dir1, dir2 - - // Find the dir1 node - var dir1Node *TreeNode - for _, child := range toNode.Children { - if child.Name == "dir1" { - dir1Node = child - break - } - } - - require.NotNil(t, dir1Node) - assert.Equal(t, "directory", dir1Node.Type) - assert.Len(t, dir1Node.Children, 2) // file2.txt and subdir -} - -func TestPrintTree(t *testing.T) { - // Create a simple tree - tree := []*TreeNode{ - { - Name: "dir1", - Path: "dir1", - Type: "directory", - Children: []*TreeNode{ - { - Name: "file1.txt", - Path: "dir1/file1.txt", - Type: "file", - }, - { - Name: "subdir", - Path: "dir1/subdir", - Type: "directory", - Children: []*TreeNode{ - { - Name: "file2.txt", - Path: "dir1/subdir/file2.txt", - Type: "file", - }, - }, - }, - }, - }, - { - Name: "file3.txt", - Path: "file3.txt", - Type: "file", - }, - } - - result := printTree(tree, "/root") - - // Check the output format - assert.Contains(t, result, "- /root/") - assert.Contains(t, result, " - dir1/") - assert.Contains(t, result, " - file1.txt") - assert.Contains(t, result, " - subdir/") - assert.Contains(t, result, " - file2.txt") - assert.Contains(t, result, " - file3.txt") -} - -func TestListDirectory(t *testing.T) { - // Create a temporary directory for testing - tempDir, err := os.MkdirTemp("", "list_directory_test") - require.NoError(t, err) - defer os.RemoveAll(tempDir) - - // Create a test directory structure - testDirs := []string{ - "dir1", - "dir1/subdir1", - ".hidden_dir", - } - - testFiles := []string{ - "file1.txt", - "file2.txt", - "dir1/file3.txt", - "dir1/subdir1/file4.txt", - ".hidden_file.txt", - } - - // Create directories - for _, dir := range testDirs { - dirPath := filepath.Join(tempDir, dir) - err := os.MkdirAll(dirPath, 0o755) - require.NoError(t, err) - } - - // Create files - for _, file := range testFiles { - filePath := filepath.Join(tempDir, file) - err := os.WriteFile(filePath, []byte("test content"), 0o644) - require.NoError(t, err) - } - - t.Run("lists files with no limit", func(t *testing.T) { - files, truncated, err := listDirectory(tempDir, []string{}, 1000) - require.NoError(t, err) - assert.False(t, truncated) - - // Check that visible files and directories are included - containsPath := func(paths []string, target string) bool { - targetPath := filepath.Join(tempDir, target) - for _, path := range paths { - if strings.HasPrefix(path, targetPath) { - return true - } - } - return false - } - - assert.True(t, containsPath(files, "dir1")) - assert.True(t, containsPath(files, "file1.txt")) - assert.True(t, containsPath(files, "file2.txt")) - assert.True(t, containsPath(files, "dir1/file3.txt")) - - // Check that hidden files and directories are not included - assert.False(t, containsPath(files, ".hidden_dir")) - assert.False(t, containsPath(files, ".hidden_file.txt")) - }) - - t.Run("respects limit and returns truncated flag", func(t *testing.T) { - files, truncated, err := listDirectory(tempDir, []string{}, 2) - require.NoError(t, err) - assert.True(t, truncated) - assert.Len(t, files, 2) - }) - - t.Run("respects ignore patterns", func(t *testing.T) { - files, truncated, err := listDirectory(tempDir, []string{"*.txt"}, 1000) - require.NoError(t, err) - assert.False(t, truncated) - - // Check that no .txt files are included - for _, file := range files { - assert.False(t, strings.HasSuffix(file, ".txt"), "Found .txt file: %s", file) - } - - // But directories should still be included - containsDir := false - for _, file := range files { - if strings.Contains(file, "dir1") { - containsDir = true - break - } - } - assert.True(t, containsDir) - }) -} diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor/editor.go similarity index 69% rename from internal/tui/components/chat/editor.go rename to internal/tui/components/chat/editor/editor.go index 430b0b4cf3f90cd399cc8fd7be73761e9cd77e92..c0f17d6f78fd579b42e1ca55acc1b7b4f7b00e8a 100644 --- a/internal/tui/components/chat/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -1,4 +1,4 @@ -package chat +package editor import ( "fmt" @@ -13,9 +13,12 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" "github.com/opencode-ai/opencode/internal/app" + "github.com/opencode-ai/opencode/internal/fileutil" "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/message" "github.com/opencode-ai/opencode/internal/session" + "github.com/opencode-ai/opencode/internal/tui/components/chat" + "github.com/opencode-ai/opencode/internal/tui/components/completions" "github.com/opencode-ai/opencode/internal/tui/components/dialog" "github.com/opencode-ai/opencode/internal/tui/layout" "github.com/opencode-ai/opencode/internal/tui/styles" @@ -23,6 +26,10 @@ import ( "github.com/opencode-ai/opencode/internal/tui/util" ) +type FileCompletionItem struct { + Path string // The file path +} + type editorCmp struct { width int height int @@ -32,35 +39,21 @@ type editorCmp struct { textarea textarea.Model attachments []message.Attachment deleteMode bool -} -type EditorKeyMaps struct { - Send key.Binding - OpenEditor key.Binding -} + keyMap EditorKeyMap -type bluredEditorKeyMaps struct { - Send key.Binding - Focus key.Binding - OpenEditor key.Binding + // File path completions + currentQuery string + completionsStartIndex int + isCompletionsOpen bool } + type DeleteAttachmentKeyMaps struct { AttachmentDeleteMode key.Binding Escape key.Binding DeleteAllAttachments key.Binding } -var editorMaps = EditorKeyMaps{ - Send: key.NewBinding( - key.WithKeys("enter", "ctrl+s"), - key.WithHelp("enter", "send message"), - ), - OpenEditor: key.NewBinding( - key.WithKeys("ctrl+e"), - key.WithHelp("ctrl+e", "open editor"), - ), -} - var DeleteKeyMaps = DeleteAttachmentKeyMaps{ AttachmentDeleteMode: key.NewBinding( key.WithKeys("ctrl+r"), @@ -109,7 +102,7 @@ func (m *editorCmp) openEditor() tea.Cmd { os.Remove(tmpfile.Name()) attachments := m.attachments m.attachments = nil - return SendMsg{ + return chat.SendMsg{ Text: string(content), Attachments: attachments, } @@ -134,7 +127,7 @@ func (m *editorCmp) send() tea.Cmd { return nil } return tea.Batch( - util.CmdHandler(SendMsg{ + util.CmdHandler(chat.SendMsg{ Text: value, Attachments: attachments, }), @@ -143,16 +136,12 @@ func (m *editorCmp) send() tea.Cmd { func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd + var cmds []tea.Cmd switch msg := msg.(type) { case dialog.ThemeChangedMsg: m.textarea = CreateTextArea(&m.textarea) - case dialog.CompletionSelectedMsg: - existingValue := m.textarea.Value() - modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1) - - m.textarea.SetValue(modifiedValue) - return m, nil - case SessionSelectedMsg: + return m, cmd + case chat.SessionSelectedMsg: if msg.ID != m.session.ID { m.session = msg } @@ -163,7 +152,64 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } m.attachments = append(m.attachments, msg.Attachment) + return m, nil + case completions.CompletionsClosedMsg: + m.isCompletionsOpen = false + m.currentQuery = "" + m.completionsStartIndex = 0 + case completions.SelectCompletionMsg: + if !m.isCompletionsOpen { + return m, nil + } + if item, ok := msg.Value.(FileCompletionItem); ok { + // If the selected item is a file, insert its path into the textarea + value := m.textarea.Value() + value = value[:m.completionsStartIndex] + if len(value) > 0 && value[len(value)-1] != ' ' { + value += " " + } + value += item.Path + m.textarea.SetValue(value) + m.isCompletionsOpen = false + m.currentQuery = "" + m.completionsStartIndex = 0 + return m, nil + } case tea.KeyPressMsg: + switch { + // Completions + case msg.String() == "/" && !m.isCompletionsOpen: + m.isCompletionsOpen = true + m.currentQuery = "" + cmds = append(cmds, m.startCompletions) + m.completionsStartIndex = len(m.textarea.Value()) + case msg.String() == "space" && m.isCompletionsOpen: + m.isCompletionsOpen = false + m.currentQuery = "" + m.completionsStartIndex = 0 + cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{})) + case m.isCompletionsOpen && m.textarea.Cursor().X <= m.completionsStartIndex: + cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{})) + case msg.String() == "backspace" && m.isCompletionsOpen: + if len(m.currentQuery) > 0 { + m.currentQuery = m.currentQuery[:len(m.currentQuery)-1] + cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{ + Query: m.currentQuery, + })) + } else { + m.isCompletionsOpen = false + m.currentQuery = "" + m.completionsStartIndex = 0 + cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{})) + } + default: + if m.isCompletionsOpen { + m.currentQuery += msg.String() + cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{ + Query: m.currentQuery, + })) + } + } if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) { m.deleteMode = true return m, nil @@ -186,7 +232,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } } - if key.Matches(msg, editorMaps.OpenEditor) { + if key.Matches(msg, m.keyMap.OpenEditor) { if m.app.CoderAgent.IsSessionBusy(m.session.ID) { return m, util.ReportWarn("Agent is working, please wait...") } @@ -197,7 +243,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } // Hanlde Enter key - if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) { + if m.textarea.Focused() && key.Matches(msg, m.keyMap.Send) { value := m.textarea.Value() if len(value) > 0 && value[len(value)-1] == '\\' { // If the last character is a backslash, remove it and add a newline @@ -210,7 +256,8 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } m.textarea, cmd = m.textarea.Update(msg) - return m, cmd + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) } func (m *editorCmp) View() tea.View { @@ -223,8 +270,8 @@ func (m *editorCmp) View() tea.View { Foreground(t.Primary()) cursor := m.textarea.Cursor() - cursor.X = m.textarea.Cursor().X + m.x + 2 - cursor.Y = m.textarea.Cursor().Y + m.y + 1 + cursor.X = cursor.X + m.x + 2 + cursor.Y = cursor.Y + m.y + 1 if len(m.attachments) == 0 { view := tea.NewView(lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())) view.SetCursor(cursor) @@ -278,7 +325,7 @@ func (m *editorCmp) attachmentsContent() string { func (m *editorCmp) BindingKeys() []key.Binding { bindings := []key.Binding{} - bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...) + bindings = append(bindings, layout.KeyMapToSlice(m.keyMap)...) bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...) return bindings } @@ -289,6 +336,28 @@ func (m *editorCmp) SetPosition(x, y int) tea.Cmd { return nil } +func (m *editorCmp) startCompletions() tea.Msg { + files, _, _ := fileutil.ListDirectory(".", []string{}, 0) + completionItems := make([]completions.Completion, 0, len(files)) + for _, file := range files { + file = strings.TrimPrefix(file, "./") + completionItems = append(completionItems, completions.Completion{ + Title: file, + Value: FileCompletionItem{ + Path: file, + }, + }) + } + + x := m.textarea.Cursor().X + m.x + 1 + y := m.textarea.Cursor().Y + m.y + 1 + return completions.OpenCompletionsMsg{ + Completions: completionItems, + X: x, + Y: y, + } +} + func CreateTextArea(existing *textarea.Model) textarea.Model { t := theme.CurrentTheme() bgColor := t.Background() @@ -333,5 +402,6 @@ func NewEditorCmp(app *app.App) util.Model { return &editorCmp{ app: app, textarea: ta, + keyMap: DefaultEditorKeyMap(), } } diff --git a/internal/tui/components/chat/editor/keys.go b/internal/tui/components/chat/editor/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..69bffd81c1ad1214be49d73bab2e36d019a87ba4 --- /dev/null +++ b/internal/tui/components/chat/editor/keys.go @@ -0,0 +1,59 @@ +package editor + +import ( + "github.com/charmbracelet/bubbles/v2/key" + "github.com/opencode-ai/opencode/internal/tui/layout" +) + +type EditorKeyMap struct { + Send key.Binding + OpenEditor key.Binding +} + +func DefaultEditorKeyMap() EditorKeyMap { + return EditorKeyMap{ + Send: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "send"), + ), + OpenEditor: key.NewBinding( + key.WithKeys("ctrl+e"), + key.WithHelp("ctrl+e", "open editor"), + ), + } +} + +// FullHelp implements help.KeyMap. +func (k EditorKeyMap) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := layout.KeyMapToSlice(k) + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// ShortHelp implements help.KeyMap. +func (k EditorKeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + k.Send, + k.OpenEditor, + } +} + +// TODO: update this to use the new keymap concepts +var AttachmentsKeyMaps = DeleteAttachmentKeyMaps{ + AttachmentDeleteMode: key.NewBinding( + key.WithKeys("ctrl+r"), + key.WithHelp("ctrl+r+{i}", "delete attachment at index i"), + ), + Escape: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel delete mode"), + ), + DeleteAllAttachments: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("ctrl+r+r", "delete all attachments"), + ), +} diff --git a/internal/tui/components/completions/completions.go b/internal/tui/components/completions/completions.go new file mode 100644 index 0000000000000000000000000000000000000000..7733aac48ccc27c4b43a61880873009d77ff0a66 --- /dev/null +++ b/internal/tui/components/completions/completions.go @@ -0,0 +1,195 @@ +package completions + +import ( + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" + "github.com/opencode-ai/opencode/internal/tui/components/core/list" + "github.com/opencode-ai/opencode/internal/tui/styles" + "github.com/opencode-ai/opencode/internal/tui/theme" + "github.com/opencode-ai/opencode/internal/tui/util" +) + +type Completion struct { + Title string // The title of the completion item + Value any // The value of the completion item +} + +type OpenCompletionsMsg struct { + Completions []Completion + X int // X position for the completions popup + Y int // Y position for the completions popup +} + +type FilterCompletionsMsg struct { + Query string // The query to filter completions +} + +type CompletionsClosedMsg struct{} + +type CloseCompletionsMsg struct{} + +type SelectCompletionMsg struct { + Value any // The value of the selected completion item +} + +type Completions interface { + util.Model + Open() bool + Query() string // Returns the current filter query + KeyMap() KeyMap + Position() (int, int) // Returns the X and Y position of the completions popup +} + +type completionsCmp struct { + width int + height int // Height of the completions component` + x int // X position for the completions popup\ + y int // Y position for the completions popup + open bool // Indicates if the completions are open + keyMap KeyMap + + list list.ListModel + query string // The current filter query +} + +func New() Completions { + completionsKeyMap := DefaultKeyMap() + keyMap := list.DefaultKeyMap() + keyMap.Up.SetEnabled(false) + keyMap.Down.SetEnabled(false) + keyMap.NDown.SetEnabled(false) + keyMap.NUp.SetEnabled(false) + keyMap.HalfPageDown.SetEnabled(false) + keyMap.HalfPageUp.SetEnabled(false) + keyMap.Home.SetEnabled(false) + keyMap.End.SetEnabled(false) + keyMap.UpOneItem = completionsKeyMap.Up + keyMap.DownOneItem = completionsKeyMap.Down + + l := list.New( + list.WithReverse(true), + list.WithKeyMap(keyMap), + list.WithHideFilterInput(true), + ) + return &completionsCmp{ + width: 30, + height: 10, + list: l, + query: "", + keyMap: completionsKeyMap, + } +} + +// Init implements Completions. +func (c *completionsCmp) Init() tea.Cmd { + return tea.Sequence( + c.list.Init(), + c.list.SetSize(c.width, c.height), + ) +} + +// Update implements Completions. +func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + case key.Matches(msg, c.keyMap.Up): + u, cmd := c.list.Update(msg) + c.list = u.(list.ListModel) + return c, cmd + + case key.Matches(msg, c.keyMap.Down): + d, cmd := c.list.Update(msg) + c.list = d.(list.ListModel) + return c, cmd + case key.Matches(msg, c.keyMap.Select): + selectedItemInx := c.list.SelectedIndex() + if selectedItemInx == list.NoSelection { + return c, nil // No item selected, do nothing + } + items := c.list.Items() + selectedItem := items[selectedItemInx].(CompletionItem).Value() + c.open = false // Close completions after selection + return c, util.CmdHandler(SelectCompletionMsg{ + Value: selectedItem, + }) + case key.Matches(msg, c.keyMap.Cancel): + if c.open { + c.open = false + return c, util.CmdHandler(CompletionsClosedMsg{}) + } + } + case CloseCompletionsMsg: + c.open = false + c.query = "" + return c, tea.Batch( + c.list.SetItems([]util.Model{}), + util.CmdHandler(CompletionsClosedMsg{}), + ) + case OpenCompletionsMsg: + c.open = true + c.query = "" + c.x = msg.X + c.y = msg.Y + items := []util.Model{} + for _, completion := range msg.Completions { + item := NewCompletionItem(completion.Title, completion.Value) + items = append(items, item) + } + c.height = max(min(10, len(items)), 1) // Ensure at least 1 item height + cmds := []tea.Cmd{ + c.list.SetSize(c.width, c.height), + c.list.SetItems(items), + } + return c, tea.Batch(cmds...) + case FilterCompletionsMsg: + c.query = msg.Query + if !c.open { + return c, nil // If completions are not open, do nothing + } + cmd := c.list.Filter(msg.Query) + c.height = max(min(10, len(c.list.Items())), 1) + return c, tea.Batch( + cmd, + c.list.SetSize(c.width, c.height), + ) + } + return c, nil +} + +// View implements Completions. +func (c *completionsCmp) View() tea.View { + if len(c.list.Items()) == 0 { + return tea.NewView(c.style().Render("No completions found")) + } + + view := tea.NewView( + c.style().Render(c.list.View().String()), + ) + return view +} + +func (c *completionsCmp) style() lipgloss.Style { + t := theme.CurrentTheme() + return styles.BaseStyle(). + Width(c.width). + Height(c.height). + Background(t.BackgroundSecondary()) +} + +func (c *completionsCmp) Open() bool { + return c.open +} + +func (c *completionsCmp) Query() string { + return c.query +} + +func (c *completionsCmp) KeyMap() KeyMap { + return c.keyMap +} + +func (c *completionsCmp) Position() (int, int) { + return c.x, c.y - c.height +} diff --git a/internal/tui/components/completions/item.go b/internal/tui/components/completions/item.go new file mode 100644 index 0000000000000000000000000000000000000000..20782645888d232a5253e2070f4e7773978b9ddc --- /dev/null +++ b/internal/tui/components/completions/item.go @@ -0,0 +1,247 @@ +package completions + +import ( + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/opencode-ai/opencode/internal/tui/components/core/list" + "github.com/opencode-ai/opencode/internal/tui/layout" + "github.com/opencode-ai/opencode/internal/tui/styles" + "github.com/opencode-ai/opencode/internal/tui/theme" + "github.com/opencode-ai/opencode/internal/tui/util" + "github.com/rivo/uniseg" +) + +type CompletionItem interface { + util.Model + layout.Focusable + layout.Sizeable + list.HasMatchIndexes + list.HasFilterValue + Value() any +} + +type completionItemCmp struct { + width int + text string + value any + focus bool + matchIndexes []int +} + +func NewCompletionItem(text string, value any, matchIndexes ...int) CompletionItem { + return &completionItemCmp{ + text: text, + value: value, + matchIndexes: matchIndexes, + } +} + +// Init implements CommandItem. +func (c *completionItemCmp) Init() tea.Cmd { + return nil +} + +// Update implements CommandItem. +func (c *completionItemCmp) Update(tea.Msg) (tea.Model, tea.Cmd) { + return c, nil +} + +// View implements CommandItem. +func (c *completionItemCmp) View() tea.View { + t := theme.CurrentTheme() + + baseStyle := styles.BaseStyle().Background(t.BackgroundSecondary()) + titleStyle := baseStyle.Padding(0, 1).Width(c.width).Foreground(t.Text()) + titleMatchStyle := baseStyle.Foreground(t.Text()).Underline(true) + + if c.focus { + titleStyle = titleStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true) + titleMatchStyle = titleMatchStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true) + } + + var truncatedTitle string + var adjustedMatchIndexes []int + + availableWidth := c.width - 2 // Account for padding + if len(c.matchIndexes) > 0 && len(c.text) > availableWidth { + // Smart truncation: ensure the last matching part is visible + truncatedTitle, adjustedMatchIndexes = c.smartTruncate(c.text, availableWidth, c.matchIndexes) + } else { + // No matches, use regular truncation + truncatedTitle = ansi.Truncate(c.text, availableWidth, "…") + adjustedMatchIndexes = c.matchIndexes + } + + text := titleStyle.Render(truncatedTitle) + if len(adjustedMatchIndexes) > 0 { + var ranges []lipgloss.Range + for _, rng := range matchedRanges(adjustedMatchIndexes) { + // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes. + // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions. + // so we need to adjust it here: + start, stop := bytePosToVisibleCharPos(text, rng) + ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle)) + } + text = lipgloss.StyleRanges(text, ranges...) + } + return tea.NewView(text) +} + +// Blur implements CommandItem. +func (c *completionItemCmp) Blur() tea.Cmd { + c.focus = false + return nil +} + +// Focus implements CommandItem. +func (c *completionItemCmp) Focus() tea.Cmd { + c.focus = true + return nil +} + +// GetSize implements CommandItem. +func (c *completionItemCmp) GetSize() (int, int) { + return c.width, 1 +} + +// IsFocused implements CommandItem. +func (c *completionItemCmp) IsFocused() bool { + return c.focus +} + +// SetSize implements CommandItem. +func (c *completionItemCmp) SetSize(width int, height int) tea.Cmd { + c.width = width + return nil +} + +func (c *completionItemCmp) MatchIndexes(indexes []int) { + c.matchIndexes = indexes + for i := range c.matchIndexes { + c.matchIndexes[i] += 1 // Adjust for the padding we add in View + } +} + +func (c *completionItemCmp) FilterValue() string { + return c.text +} + +func (c *completionItemCmp) Value() any { + return c.value +} + +// smartTruncate implements fzf-style truncation that ensures the last matching part is visible +func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes []int) (string, []int) { + if width <= 0 { + return "", []int{} + } + + textLen := ansi.StringWidth(text) + if textLen <= width { + return text, matchIndexes + } + + if len(matchIndexes) == 0 { + return ansi.Truncate(text, width, "…"), []int{} + } + + // Find the last match position + lastMatchPos := matchIndexes[len(matchIndexes)-1] + + // Convert byte position to visual width position + lastMatchVisualPos := 0 + bytePos := 0 + gr := uniseg.NewGraphemes(text) + for bytePos < lastMatchPos && gr.Next() { + bytePos += len(gr.Str()) + lastMatchVisualPos += max(1, gr.Width()) + } + + // Calculate how much space we need for the ellipsis + ellipsisWidth := 1 // "…" character width + availableWidth := width - ellipsisWidth + + // If the last match is within the available width, truncate from the end + if lastMatchVisualPos < availableWidth { + return ansi.Truncate(text, width, "…"), matchIndexes + } + + // Calculate the start position to ensure the last match is visible + // We want to show some context before the last match if possible + startVisualPos := max(0, lastMatchVisualPos-availableWidth+1) + + // Convert visual position back to byte position + startBytePos := 0 + currentVisualPos := 0 + gr = uniseg.NewGraphemes(text) + for currentVisualPos < startVisualPos && gr.Next() { + startBytePos += len(gr.Str()) + currentVisualPos += max(1, gr.Width()) + } + + // Extract the substring starting from startBytePos + truncatedText := text[startBytePos:] + + // Truncate to fit width with ellipsis + truncatedText = ansi.Truncate(truncatedText, availableWidth, "") + truncatedText = "…" + truncatedText + + // Adjust match indexes for the new truncated string + adjustedIndexes := []int{} + for _, idx := range matchIndexes { + if idx >= startBytePos { + newIdx := idx - startBytePos + 1 // + // Check if this match is still within the truncated string + if newIdx < len(truncatedText) { + adjustedIndexes = append(adjustedIndexes, newIdx) + } + } + } + + return truncatedText, adjustedIndexes +} + +func matchedRanges(in []int) [][2]int { + if len(in) == 0 { + return [][2]int{} + } + current := [2]int{in[0], in[0]} + if len(in) == 1 { + return [][2]int{current} + } + var out [][2]int + for i := 1; i < len(in); i++ { + if in[i] == current[1]+1 { + current[1] = in[i] + } else { + out = append(out, current) + current = [2]int{in[i], in[i]} + } + } + out = append(out, current) + return out +} + +func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) { + bytePos, byteStart, byteStop := 0, rng[0], rng[1] + pos, start, stop := 0, 0, 0 + gr := uniseg.NewGraphemes(str) + for byteStart > bytePos { + if !gr.Next() { + break + } + bytePos += len(gr.Str()) + pos += max(1, gr.Width()) + } + start = pos + for byteStop > bytePos { + if !gr.Next() { + break + } + bytePos += len(gr.Str()) + pos += max(1, gr.Width()) + } + stop = pos + return start, stop +} diff --git a/internal/tui/components/completions/keys.go b/internal/tui/components/completions/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..c135df01bfe4774d9bef57da4b6cfc28e4034405 --- /dev/null +++ b/internal/tui/components/completions/keys.go @@ -0,0 +1,53 @@ +package completions + +import ( + "github.com/charmbracelet/bubbles/v2/key" + "github.com/opencode-ai/opencode/internal/tui/layout" +) + +type KeyMap struct { + Down, + Up, + Select, + Cancel key.Binding +} + +func DefaultKeyMap() KeyMap { + return KeyMap{ + Down: key.NewBinding( + key.WithKeys("down"), + key.WithHelp("down", "move down"), + ), + Up: key.NewBinding( + key.WithKeys("up"), + key.WithHelp("up", "move up"), + ), + Select: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select"), + ), + Cancel: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + } +} + +// FullHelp implements help.KeyMap. +func (k KeyMap) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := layout.KeyMapToSlice(k) + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// ShortHelp implements help.KeyMap. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + k.Up, + k.Down, + } +} diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go index c1a678ab0a4af6fce0cd62f7c8972e7656c7e8ae..3a7290967a96382fc86e2fc7d1e9aeba6fede8c8 100644 --- a/internal/tui/components/core/list/list.go +++ b/internal/tui/components/core/list/list.go @@ -39,6 +39,7 @@ type ListModel interface { ResetView() // Clear rendering cache and reset scroll position Items() []util.Model // Get all items in the list SelectedIndex() int // Get the index of the currently selected item + Filter(string) tea.Cmd // Filter items based on a search term } // HasAnim interface identifies items that support animation. @@ -50,13 +51,11 @@ type HasAnim interface { // HasFilterValue interface allows items to provide a filter value for searching. type HasFilterValue interface { - util.Model FilterValue() string // Returns a string value used for filtering/searching } // HasMatchIndexes interface allows items to set matched character indexes. type HasMatchIndexes interface { - util.Model MatchIndexes([]int) // Sets the indexes of matched characters in the item's content } @@ -134,10 +133,11 @@ type model struct { gapSize int // Number of empty lines between items padding []int // Padding around the list content - filterable bool // Whether items can be filtered - filteredItems []util.Model // Filtered items based on current search - input textinput.Model // Input field for filtering items - currentSearch string // Current search term for filtering + filterable bool // Whether items can be filtered + filteredItems []util.Model // Filtered items based on current search + input textinput.Model // Input field for filtering items + hideFilterInput bool // Whether to hide the filter input field + currentSearch string // Current search term for filtering } // listOptions is a function type for configuring list options. @@ -188,6 +188,13 @@ func WithFilterable(filterable bool) listOptions { } } +// WithHideFilterInput hides the filter input field. +func WithHideFilterInput(hide bool) listOptions { + return func(m *model) { + m.hideFilterInput = hide + } +} + // New creates a new list model with the specified options. // The list starts with no items selected and requires SetItems to be called // or items to be provided via WithItems option. @@ -206,7 +213,7 @@ func New(opts ...listOptions) ListModel { opt(m) } - if m.filterable { + if m.filterable && !m.hideFilterInput { ti := textinput.New() ti.Placeholder = "Type to filter..." ti.SetVirtualCursor(false) @@ -259,7 +266,7 @@ func (m *model) View() tea.View { Height(m.viewState.height). Render(m.viewState.content) - if m.filterable { + if m.filterable && !m.hideFilterInput { content = lipgloss.JoinVertical( lipgloss.Left, m.inputStyle().Render(m.input.View()), @@ -267,7 +274,7 @@ func (m *model) View() tea.View { ) } view := tea.NewView(content) - if m.filterable { + if m.filterable && !m.hideFilterInput { view.SetCursor(m.input.Cursor()) } return view @@ -294,15 +301,15 @@ func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keyMap.End): return m, m.goToBottom() default: - if !m.filterable { - return m, nil // Ignore other keys if not filterable + if !m.filterable || m.hideFilterInput { + return m, nil // Ignore other keys if not filterable or input is hidden } var cmds []tea.Cmd u, cmd := m.input.Update(msg) m.input = u cmds = append(cmds, cmd) if m.currentSearch != m.input.Value() { - cmd = m.filter(m.input.Value()) + cmd = m.Filter(m.input.Value()) cmds = append(cmds, cmd) } m.currentSearch = m.input.Value() @@ -923,7 +930,7 @@ func (m *model) GetSize() (int, int) { // SetSize updates the list dimensions and triggers a complete re-render. // Also updates the size of all items that support sizing. func (m *model) SetSize(width int, height int) tea.Cmd { - if m.filterable { + if m.filterable && !m.hideFilterInput { height -= 2 // adjust for input field height and border } @@ -936,7 +943,7 @@ func (m *model) SetSize(width int, height int) tea.Cmd { } m.viewState.width = width m.ResetView() - if m.filterable { + if m.filterable && !m.hideFilterInput { m.input.SetWidth(m.getItemWidth() - 3) } return m.setAllItemsSize() @@ -1152,7 +1159,7 @@ func (m *model) flattenSections(sections []section) []util.Model { return result } -func (m *model) filter(search string) tea.Cmd { +func (m *model) Filter(search string) tea.Cmd { var cmds []tea.Cmd search = strings.TrimSpace(search) search = strings.ToLower(search) @@ -1189,6 +1196,7 @@ func (m *model) filter(search string) tea.Cmd { // Set initial selection if len(m.filteredItems) > 0 { if m.viewState.reverse { + slices.Reverse(m.filteredItems) m.selectionState.selectedIndex = m.findLastSelectableItem() } else { m.selectionState.selectedIndex = m.findFirstSelectableItem() diff --git a/internal/tui/components/dialog/arguments.go b/internal/tui/components/dialog/arguments.go deleted file mode 100644 index 5c289ddd25bd44f6d4ae070eef73b995ed3fd00b..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialog/arguments.go +++ /dev/null @@ -1,252 +0,0 @@ -package dialog - -import ( - "fmt" - - "github.com/charmbracelet/bubbles/v2/key" - "github.com/charmbracelet/bubbles/v2/textinput" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" - - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" - "github.com/opencode-ai/opencode/internal/tui/util" -) - -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()} -} - -// 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.SetWidth(40) - ti.Prompt = "" - styles := ti.Styles() - styles.Focused.Placeholder = styles.Focused.Placeholder.Background(t.Background()) - styles.Blurred.Placeholder = styles.Blurred.Placeholder.Background(t.Background()) - styles.Focused.Suggestion = styles.Focused.Suggestion.Background(t.Background()).Foreground(t.Primary()) - styles.Blurred.Suggestion = styles.Blurred.Suggestion.Background(t.Background()) - styles.Focused.Text = styles.Focused.Text.Background(t.Background()).Foreground(t.Primary()) - styles.Blurred.Text = styles.Blurred.Text.Background(t.Background()) - - // Only focus the first input initially - if i == 0 { - ti.Focus() - } 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 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 MultiArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - switch msg := msg.(type) { - case tea.KeyPressMsg: - switch { - case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))): - return m, util.CmdHandler(CloseMultiArgumentsDialogMsg{ - Submit: false, - CommandID: m.commandID, - Content: m.content, - 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() - 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() - 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() - } - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - } - - // 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 MultiArgumentsDialogCmp) View() string { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - // Calculate width needed for content - maxWidth := 60 // Width for explanation text - - title := lipgloss.NewStyle(). - Foreground(t.Primary()). - Bold(true). - Width(maxWidth). - Padding(0, 1). - Background(t.Background()). - Render("Command Arguments") - - explanation := lipgloss.NewStyle(). - Foreground(t.Text()). - Width(maxWidth). - Padding(0, 1). - Background(t.Background()). - Render("This command requires multiple arguments. Please enter values for each:") - - // 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, - elements..., - ) - - 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 *MultiArgumentsDialogCmp) SetSize(width, height int) { - m.width = width - m.height = height -} - -// Bindings implements layout.Bindings. -func (m MultiArgumentsDialogCmp) Bindings() []key.Binding { - return m.keys.ShortHelp() -} diff --git a/internal/tui/components/dialog/commands.go b/internal/tui/components/dialog/commands.go deleted file mode 100644 index 1e60d3ed1f317c2729ce33ae3e62f5867a2245e6..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialog/commands.go +++ /dev/null @@ -1,182 +0,0 @@ -package dialog - -import ( - "github.com/charmbracelet/bubbles/v2/key" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" - utilComponents "github.com/opencode-ai/opencode/internal/tui/components/util" - "github.com/opencode-ai/opencode/internal/tui/layout" - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" - "github.com/opencode-ai/opencode/internal/tui/util" -) - -// Command represents a command that can be executed -type Command struct { - ID string - Title string - Description string - Handler func(cmd Command) tea.Cmd -} - -func (ci Command) Render(selected bool, width int) string { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - descStyle := baseStyle.Width(width).Foreground(t.TextMuted()) - itemStyle := baseStyle.Width(width). - Foreground(t.Text()). - Background(t.Background()) - - if selected { - itemStyle = itemStyle. - Background(t.Primary()). - Foreground(t.Background()). - Bold(true) - descStyle = descStyle. - Background(t.Primary()). - Foreground(t.Background()) - } - - title := itemStyle.Padding(0, 1).Render(ci.Title) - if ci.Description != "" { - description := descStyle.Padding(0, 1).Render(ci.Description) - return lipgloss.JoinVertical(lipgloss.Left, title, description) - } - return title -} - -// CommandSelectedMsg is sent when a command is selected -type CommandSelectedMsg struct { - Command Command -} - -// CloseCommandDialogMsg is sent when the command dialog is closed -type CloseCommandDialogMsg struct{} - -// CommandDialog interface for the command selection dialog -type CommandDialog interface { - util.Model - layout.Bindings - SetCommands(commands []Command) -} - -type commandDialogCmp struct { - listView utilComponents.SimpleList[Command] - width int - height int -} - -type commandKeyMap struct { - Enter key.Binding - Escape key.Binding -} - -var commandKeys = commandKeyMap{ - Enter: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "select command"), - ), - Escape: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "close"), - ), -} - -func (c *commandDialogCmp) Init() tea.Cmd { - return c.listView.Init() -} - -func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - switch msg := msg.(type) { - case tea.KeyPressMsg: - switch { - case key.Matches(msg, commandKeys.Enter): - selectedItem, idx := c.listView.GetSelectedItem() - if idx != -1 { - return c, util.CmdHandler(CommandSelectedMsg{ - Command: selectedItem, - }) - } - case key.Matches(msg, commandKeys.Escape): - return c, util.CmdHandler(CloseCommandDialogMsg{}) - } - case tea.WindowSizeMsg: - c.width = msg.Width - c.height = msg.Height - } - - u, cmd := c.listView.Update(msg) - c.listView = u.(utilComponents.SimpleList[Command]) - cmds = append(cmds, cmd) - - return c, tea.Batch(cmds...) -} - -func (c *commandDialogCmp) View() tea.View { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - maxWidth := 40 - - commands := c.listView.GetItems() - - for _, cmd := range commands { - if len(cmd.Title) > maxWidth-4 { - maxWidth = len(cmd.Title) + 4 - } - if cmd.Description != "" { - if len(cmd.Description) > maxWidth-4 { - maxWidth = len(cmd.Description) + 4 - } - } - } - - c.listView.SetMaxWidth(maxWidth) - - title := baseStyle. - Foreground(t.Primary()). - Bold(true). - Width(maxWidth). - Padding(0, 1). - Render("Commands") - - content := lipgloss.JoinVertical( - lipgloss.Left, - title, - baseStyle.Width(maxWidth).Render(""), - baseStyle.Width(maxWidth).Render(c.listView.View().String()), - baseStyle.Width(maxWidth).Render(""), - ) - - return tea.NewView( - baseStyle.Padding(1, 2). - Border(lipgloss.RoundedBorder()). - BorderBackground(t.Background()). - BorderForeground(t.TextMuted()). - Width(lipgloss.Width(content) + 4). - Render(content), - ) -} - -func (c *commandDialogCmp) BindingKeys() []key.Binding { - return layout.KeyMapToSlice(commandKeys) -} - -func (c *commandDialogCmp) SetCommands(commands []Command) { - c.listView.SetItems(commands) -} - -// NewCommandDialogCmp creates a new command selection dialog -func NewCommandDialogCmp() CommandDialog { - listView := utilComponents.NewSimpleList[Command]( - []Command{}, - 10, - "No commands available", - true, - ) - return &commandDialogCmp{ - listView: listView, - } -} diff --git a/internal/tui/components/dialog/complete.go b/internal/tui/components/dialog/complete.go deleted file mode 100644 index d5cf1519a91c1cd5c6e3572cb33f43f84d64b7e6..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialog/complete.go +++ /dev/null @@ -1,264 +0,0 @@ -package dialog - -import ( - "github.com/charmbracelet/bubbles/v2/key" - "github.com/charmbracelet/bubbles/v2/textarea" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" - "github.com/opencode-ai/opencode/internal/logging" - utilComponents "github.com/opencode-ai/opencode/internal/tui/components/util" - "github.com/opencode-ai/opencode/internal/tui/layout" - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" - "github.com/opencode-ai/opencode/internal/tui/util" -) - -type CompletionItem struct { - title string - Title string - Value string -} - -type CompletionItemI interface { - utilComponents.SimpleListItem - GetValue() string - DisplayValue() string -} - -func (ci *CompletionItem) Render(selected bool, width int) string { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - itemStyle := baseStyle. - Width(width). - Padding(0, 1) - - if selected { - itemStyle = itemStyle. - Background(t.Background()). - Foreground(t.Primary()). - Bold(true) - } - - title := itemStyle.Render( - ci.GetValue(), - ) - - return title -} - -func (ci *CompletionItem) DisplayValue() string { - return ci.Title -} - -func (ci *CompletionItem) GetValue() string { - return ci.Value -} - -func NewCompletionItem(completionItem CompletionItem) CompletionItemI { - return &completionItem -} - -type CompletionProvider interface { - GetId() string - GetEntry() CompletionItemI - GetChildEntries(query string) ([]CompletionItemI, error) -} - -type CompletionSelectedMsg struct { - SearchString string - CompletionValue string -} - -type CompletionDialogCompleteItemMsg struct { - Value string -} - -type CompletionDialogCloseMsg struct{} - -type CompletionDialog interface { - util.Model - layout.Bindings - SetWidth(width int) -} - -type completionDialogCmp struct { - query string - completionProvider CompletionProvider - width int - height int - pseudoSearchTextArea textarea.Model - listView utilComponents.SimpleList[CompletionItemI] -} - -type completionDialogKeyMap struct { - Complete key.Binding - Cancel key.Binding -} - -var completionDialogKeys = completionDialogKeyMap{ - Complete: key.NewBinding( - key.WithKeys("tab", "enter"), - ), - Cancel: key.NewBinding( - key.WithKeys(" ", "esc", "backspace"), - ), -} - -func (c *completionDialogCmp) Init() tea.Cmd { - return nil -} - -func (c *completionDialogCmp) complete(item CompletionItemI) tea.Cmd { - value := c.pseudoSearchTextArea.Value() - - if value == "" { - return nil - } - - return tea.Batch( - util.CmdHandler(CompletionSelectedMsg{ - SearchString: value, - CompletionValue: item.GetValue(), - }), - c.close(), - ) -} - -func (c *completionDialogCmp) close() tea.Cmd { - c.listView.SetItems([]CompletionItemI{}) - c.pseudoSearchTextArea.Reset() - c.pseudoSearchTextArea.Blur() - - return util.CmdHandler(CompletionDialogCloseMsg{}) -} - -func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - switch msg := msg.(type) { - case tea.KeyPressMsg: - if c.pseudoSearchTextArea.Focused() { - if !key.Matches(msg, completionDialogKeys.Complete) { - var cmd tea.Cmd - c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg) - cmds = append(cmds, cmd) - - var query string - query = c.pseudoSearchTextArea.Value() - if query != "" { - query = query[1:] - } - - if query != c.query { - logging.Info("Query", query) - items, err := c.completionProvider.GetChildEntries(query) - if err != nil { - logging.Error("Failed to get child entries", err) - } - - c.listView.SetItems(items) - c.query = query - } - - u, cmd := c.listView.Update(msg) - c.listView = u.(utilComponents.SimpleList[CompletionItemI]) - - cmds = append(cmds, cmd) - } - - switch { - case key.Matches(msg, completionDialogKeys.Complete): - item, i := c.listView.GetSelectedItem() - if i == -1 { - return c, nil - } - - cmd := c.complete(item) - - return c, cmd - case key.Matches(msg, completionDialogKeys.Cancel): - // Only close on backspace when there are no characters left - if msg.String() != "backspace" || len(c.pseudoSearchTextArea.Value()) <= 0 { - return c, c.close() - } - } - - return c, tea.Batch(cmds...) - } else { - items, err := c.completionProvider.GetChildEntries("") - if err != nil { - logging.Error("Failed to get child entries", err) - } - - c.listView.SetItems(items) - c.pseudoSearchTextArea.SetValue(msg.String()) - return c, c.pseudoSearchTextArea.Focus() - } - case tea.WindowSizeMsg: - c.width = msg.Width - c.height = msg.Height - } - - return c, tea.Batch(cmds...) -} - -func (c *completionDialogCmp) View() tea.View { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - maxWidth := 40 - - completions := c.listView.GetItems() - - for _, cmd := range completions { - title := cmd.DisplayValue() - if len(title) > maxWidth-4 { - maxWidth = len(title) + 4 - } - } - - c.listView.SetMaxWidth(maxWidth) - - return tea.NewView( - baseStyle.Padding(0, 0). - Border(lipgloss.NormalBorder()). - BorderBottom(false). - BorderRight(false). - BorderLeft(false). - BorderBackground(t.Background()). - BorderForeground(t.TextMuted()). - Width(c.width). - Render(c.listView.View().String()), - ) -} - -func (c *completionDialogCmp) SetWidth(width int) { - c.width = width -} - -func (c *completionDialogCmp) BindingKeys() []key.Binding { - return layout.KeyMapToSlice(completionDialogKeys) -} - -func NewCompletionDialogCmp(completionProvider CompletionProvider) CompletionDialog { - ti := textarea.New() - - items, err := completionProvider.GetChildEntries("") - if err != nil { - logging.Error("Failed to get child entries", err) - } - - li := utilComponents.NewSimpleList( - items, - 7, - "No file matches found", - false, - ) - - return &completionDialogCmp{ - query: "", - completionProvider: completionProvider, - pseudoSearchTextArea: ti, - listView: li, - } -} diff --git a/internal/tui/components/dialog/custom_commands.go b/internal/tui/components/dialog/custom_commands.go deleted file mode 100644 index dd2ae57148ee07ef1a88087d93525a4f439bdc54..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialog/custom_commands.go +++ /dev/null @@ -1,185 +0,0 @@ -package dialog - -import ( - "fmt" - "os" - "path/filepath" - "regexp" - "strings" - - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/tui/util" -) - -// Command prefix constants -const ( - UserCommandPrefix = "user:" - 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() - 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, 0o755); 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 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 - }) - }, - } - - 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 - 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 deleted file mode 100644 index c21eaaa548adc563b6dc4c75125c588c9782b061..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialog/custom_commands_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package dialog - -import ( - "regexp" - "testing" -) - -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) - } - } -} diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go index 55cfefd5af592854cb38161f0e7e546a6e71b295..07292ae123a4b220c01fd9e51c9e0754634ca561 100644 --- a/internal/tui/components/dialogs/commands/commands.go +++ b/internal/tui/components/dialogs/commands/commands.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/lipgloss/v2" "github.com/opencode-ai/opencode/internal/tui/components/chat" + "github.com/opencode-ai/opencode/internal/tui/components/completions" "github.com/opencode-ai/opencode/internal/tui/components/core/list" "github.com/opencode-ai/opencode/internal/tui/components/dialogs" "github.com/opencode-ai/opencode/internal/tui/styles" @@ -75,9 +76,9 @@ func (c *commandDialogCmp) Init() tea.Cmd { commandItems := []util.Model{} if len(commands) > 0 { - commandItems = append(commandItems, NewItemSection("Custom")) + commandItems = append(commandItems, NewItemSection("Custom Commands")) for _, cmd := range commands { - commandItems = append(commandItems, NewCommandItem(cmd)) + commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd)) } } @@ -85,7 +86,7 @@ func (c *commandDialogCmp) Init() tea.Cmd { for _, cmd := range c.defaultCommands() { c.commands = append(c.commands, cmd) - commandItems = append(commandItems, NewCommandItem(cmd)) + commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd)) } c.commandList.SetItems(commandItems) @@ -106,7 +107,7 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return c, nil // No item selected, do nothing } items := c.commandList.Items() - selectedItem := items[selectedItemInx].(CommandItem).Command() + selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(Command) return c, tea.Sequence( util.CmdHandler(dialogs.CloseDialogMsg{}), selectedItem.Handler(selectedItem), diff --git a/internal/tui/components/dialogs/commands/item.go b/internal/tui/components/dialogs/commands/item.go index e656c1c3a6133763f7bc4bc78c438b9c84f4c3b1..26974d5082046aaa05477f95fddfdeca889c98dc 100644 --- a/internal/tui/components/dialogs/commands/item.go +++ b/internal/tui/components/dialogs/commands/item.go @@ -11,153 +11,8 @@ import ( "github.com/opencode-ai/opencode/internal/tui/styles" "github.com/opencode-ai/opencode/internal/tui/theme" "github.com/opencode-ai/opencode/internal/tui/util" - "github.com/rivo/uniseg" ) -type CommandItem interface { - util.Model - layout.Focusable - layout.Sizeable - Command() Command -} - -type commandItem struct { - width int - command Command - focus bool - matchIndexes []int -} - -func NewCommandItem(command Command) CommandItem { - return &commandItem{ - command: command, - matchIndexes: make([]int, 0), - } -} - -// Init implements CommandItem. -func (c *commandItem) Init() tea.Cmd { - return nil -} - -// Update implements CommandItem. -func (c *commandItem) Update(tea.Msg) (tea.Model, tea.Cmd) { - return c, nil -} - -// View implements CommandItem. -func (c *commandItem) View() tea.View { - t := theme.CurrentTheme() - - baseStyle := styles.BaseStyle() - titleStyle := baseStyle.Width(c.width).Foreground(t.Text()) - titleMatchStyle := baseStyle.Foreground(t.Text()).Underline(true) - - if c.focus { - titleStyle = titleStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true) - titleMatchStyle = titleMatchStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true) - } - var ranges []lipgloss.Range - truncatedTitle := ansi.Truncate(c.command.Title, c.width, "…") - text := titleStyle.Render(truncatedTitle) - if len(c.matchIndexes) > 0 { - for _, rng := range matchedRanges(c.matchIndexes) { - // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes. - // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions. - // so we need to adjust it here: - start, stop := bytePosToVisibleCharPos(text, rng) - ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle)) - } - text = lipgloss.StyleRanges(text, ranges...) - } - return tea.NewView(text) -} - -// Command implements CommandItem. -func (c *commandItem) Command() Command { - return c.command -} - -// Blur implements CommandItem. -func (c *commandItem) Blur() tea.Cmd { - c.focus = false - return nil -} - -// Focus implements CommandItem. -func (c *commandItem) Focus() tea.Cmd { - c.focus = true - return nil -} - -// IsFocused implements CommandItem. -func (c *commandItem) IsFocused() bool { - return c.focus -} - -// GetSize implements CommandItem. -func (c *commandItem) GetSize() (int, int) { - return c.width, 2 -} - -// SetSize implements CommandItem. -func (c *commandItem) SetSize(width int, height int) tea.Cmd { - c.width = width - return nil -} - -func (c *commandItem) FilterValue() string { - return c.command.Title -} - -func (c *commandItem) MatchIndexes(indexes []int) { - c.matchIndexes = indexes -} - -func matchedRanges(in []int) [][2]int { - if len(in) == 0 { - return [][2]int{} - } - current := [2]int{in[0], in[0]} - if len(in) == 1 { - return [][2]int{current} - } - var out [][2]int - for i := 1; i < len(in); i++ { - if in[i] == current[1]+1 { - current[1] = in[i] - } else { - out = append(out, current) - current = [2]int{in[i], in[i]} - } - } - out = append(out, current) - return out -} - -func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) { - bytePos, byteStart, byteStop := 0, rng[0], rng[1] - pos, start, stop := 0, 0, 0 - gr := uniseg.NewGraphemes(str) - for byteStart > bytePos { - if !gr.Next() { - break - } - bytePos += len(gr.Str()) - pos += max(1, gr.Width()) - } - start = pos - for byteStop > bytePos { - if !gr.Next() { - break - } - bytePos += len(gr.Str()) - pos += max(1, gr.Width()) - } - stop = pos - return start, stop -} - type ItemSection interface { util.Model layout.Sizeable diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go index 92166ca02e9f934db50a226d5b357736031ab4d3..684e95df2509af4a3af2eb6b9146f27935a22d8a 100644 --- a/internal/tui/page/chat.go +++ b/internal/tui/page/chat.go @@ -2,18 +2,16 @@ package page import ( "context" - "strings" "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" "github.com/opencode-ai/opencode/internal/app" - "github.com/opencode-ai/opencode/internal/completions" "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/message" "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/components/chat/editor" + "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands" "github.com/opencode-ai/opencode/internal/tui/layout" "github.com/opencode-ai/opencode/internal/tui/util" ) @@ -21,26 +19,19 @@ import ( var ChatPage PageID = "chat" type chatPage struct { - app *app.App - editor layout.Container - messages layout.Container - layout layout.SplitPaneLayout - session session.Session - completionDialog dialog.CompletionDialog - showCompletionDialog bool + app *app.App + editor layout.Container + messages layout.Container + layout layout.SplitPaneLayout + session session.Session } type ChatKeyMap struct { - ShowCompletionDialog key.Binding - NewSession key.Binding - Cancel key.Binding + NewSession key.Binding + Cancel key.Binding } var keyMap = ChatKeyMap{ - ShowCompletionDialog: key.NewBinding( - key.WithKeys("@"), - key.WithHelp("@", "Complete"), - ), NewSession: key.NewBinding( key.WithKeys("ctrl+n"), key.WithHelp("ctrl+n", "new session"), @@ -52,11 +43,7 @@ var keyMap = ChatKeyMap{ } func (p *chatPage) Init() tea.Cmd { - cmds := []tea.Cmd{ - p.layout.Init(), - p.completionDialog.Init(), - } - return tea.Batch(cmds...) + return p.layout.Init() } func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -66,31 +53,19 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { logging.Info("Window size changed Chat:", "Width", msg.Width, "Height", msg.Height) cmd := p.layout.SetSize(msg.Width, msg.Height) cmds = append(cmds, cmd) - case dialog.CompletionDialogCloseMsg: - p.showCompletionDialog = false case chat.SendMsg: cmd := p.sendMessage(msg.Text, msg.Attachments) if cmd != nil { return p, cmd } - case dialog.CommandRunCustomMsg: + case commands.CommandRunCustomMsg: // Check if the agent is busy before executing custom commands 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(content, nil) + cmd := p.sendMessage(msg.Content, nil) if cmd != nil { return p, cmd } @@ -104,9 +79,6 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { p.session = msg case tea.KeyPressMsg: switch { - case key.Matches(msg, keyMap.ShowCompletionDialog): - p.showCompletionDialog = true - // Continue sending keys to layout->chat case key.Matches(msg, keyMap.NewSession): p.session = session.Session{} return p, tea.Batch( @@ -122,19 +94,6 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } - if p.showCompletionDialog { - context, contextCmd := p.completionDialog.Update(msg) - p.completionDialog = context.(dialog.CompletionDialog) - cmds = append(cmds, contextCmd) - - // Doesn't forward event if enter key is pressed - if keyMsg, ok := msg.(tea.KeyPressMsg); ok { - if keyMsg.String() == "enter" { - return p, tea.Batch(cmds...) - } - } - } - u, cmd := p.layout.Update(msg) cmds = append(cmds, cmd) p.layout = u.(layout.SplitPaneLayout) @@ -186,30 +145,7 @@ func (p *chatPage) GetSize() (int, int) { } func (p *chatPage) View() tea.View { - layoutView := p.layout.View() - - if p.showCompletionDialog { - _, layoutHeight := p.layout.GetSize() - editorWidth, editorHeight := p.editor.GetSize() - - p.completionDialog.SetWidth(editorWidth) - overlay := p.completionDialog.View() - - viewStr := layout.PlaceOverlay( - 0, - layoutHeight-editorHeight-lipgloss.Height(overlay.String()), - overlay.String(), - layoutView.String(), - false, - ) - - view := tea.NewView(viewStr) - view.SetCursor(overlay.Cursor()) - return view - } - - logging.Info("Cursor in page", "c", layoutView.Cursor()) - return layoutView + return p.layout.View() } func (p *chatPage) BindingKeys() []key.Binding { @@ -220,22 +156,18 @@ func (p *chatPage) BindingKeys() []key.Binding { } func NewChatPage(app *app.App) util.Model { - cg := completions.NewFileAndFolderContextGroup() - completionDialog := dialog.NewCompletionDialogCmp(cg) - messagesContainer := layout.NewContainer( chat.NewMessagesListCmp(app), layout.WithPadding(1, 1, 0, 1), ) editorContainer := layout.NewContainer( - chat.NewEditorCmp(app), + editor.NewEditorCmp(app), layout.WithBorder(true, false, false, false), ) return &chatPage{ - app: app, - editor: editorContainer, - messages: messagesContainer, - completionDialog: completionDialog, + app: app, + editor: editorContainer, + messages: messagesContainer, layout: layout.NewSplitPane( layout.WithLeftPanel(messagesContainer), layout.WithBottomPanel(editorContainer), diff --git a/internal/tui/tui.go b/internal/tui/tui.go index f2b99e0711915c402583c05ca77142d3a047af6c..9e8a62a676e3e89545984de7903e3e32d48d58ea 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -7,6 +7,7 @@ import ( "github.com/opencode-ai/opencode/internal/app" "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/pubsub" + "github.com/opencode-ai/opencode/internal/tui/components/completions" "github.com/opencode-ai/opencode/internal/tui/components/core" "github.com/opencode-ai/opencode/internal/tui/components/dialogs" "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands" @@ -30,7 +31,8 @@ type appModel struct { app *app.App - dialog dialogs.DialogCmp + dialog dialogs.DialogCmp + completions completions.Completions } func (a appModel) Init() tea.Cmd { @@ -52,6 +54,12 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: return a, a.handleWindowResize(msg) + // Completions messages + case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg, completions.CloseCompletionsMsg: + u, completionCmd := a.completions.Update(msg) + a.completions = u.(completions.Completions) + return a, completionCmd + // Dialog messages case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg: u, dialogCmd := a.dialog.Update(msg) @@ -128,6 +136,24 @@ func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd { func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { switch { + // completions + case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Up): + u, cmd := a.completions.Update(msg) + a.completions = u.(completions.Completions) + return cmd + + case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Down): + u, cmd := a.completions.Update(msg) + a.completions = u.(completions.Completions) + return cmd + case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Select): + u, cmd := a.completions.Update(msg) + a.completions = u.(completions.Completions) + return cmd + case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Cancel): + u, cmd := a.completions.Update(msg) + a.completions = u.(completions.Completions) + return cmd // dialogs case key.Matches(msg, a.keyMap.Quit): return util.CmdHandler(dialogs.OpenDialogMsg{ @@ -191,28 +217,38 @@ func (a *appModel) View() tea.View { components = append(components, a.status.View().String()) appView := lipgloss.JoinVertical(lipgloss.Top, components...) - + layers := []*lipgloss.Layer{ + lipgloss.NewLayer(appView), + } t := theme.CurrentTheme() if a.dialog.HasDialogs() { - layers := append( - []*lipgloss.Layer{ - lipgloss.NewLayer(appView), - }, + layers = append( + layers, a.dialog.GetLayers()..., ) - canvas := lipgloss.NewCanvas( - layers..., + } + + cursor := pageView.Cursor() + activeView := a.dialog.ActiveView() + if activeView != nil { + cursor = activeView.Cursor() + } + + if a.completions.Open() && cursor != nil { + cmp := a.completions.View().String() + x, y := a.completions.Position() + layers = append( + layers, + lipgloss.NewLayer(cmp).X(x).Y(y), ) - view := tea.NewView(canvas.Render()) - activeView := a.dialog.ActiveView() - view.SetBackgroundColor(t.Background()) - view.SetCursor(activeView.Cursor()) - return view } - view := tea.NewView(appView) - view.SetCursor(pageView.Cursor()) + canvas := lipgloss.NewCanvas( + layers..., + ) + view := tea.NewView(canvas.Render()) view.SetBackgroundColor(t.Background()) + view.SetCursor(cursor) return view } @@ -230,7 +266,8 @@ func New(app *app.App) tea.Model { page.LogsPage: page.NewLogsPage(), }, - dialog: dialogs.NewDialogCmp(), + dialog: dialogs.NewDialogCmp(), + completions: completions.New(), } return model