Detailed changes
@@ -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
@@ -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=
@@ -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",
- }
-}
@@ -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 {
@@ -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
+}
@@ -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) {
@@ -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)
@@ -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)
- })
-}
@@ -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(),
}
}
@@ -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"),
+ ),
+}
@@ -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
+}
@@ -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
+}
@@ -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,
+ }
+}
@@ -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()
@@ -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()
-}
@@ -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,
- }
-}
@@ -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,
- }
-}
@@ -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
-}
@@ -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)
- }
- }
-}
@@ -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),
@@ -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
@@ -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),
@@ -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