implement completions

Kujtim Hoxha created

Change summary

go.mod                                                 |   3 
go.sum                                                 |   8 
internal/completions/files-folders.go                  | 191 -----
internal/fileutil/fileutil.go                          | 101 +
internal/fileutil/ls.go                                | 169 ++++
internal/llm/tools/glob.go                             |   2 
internal/llm/tools/ls.go                               |  98 --
internal/llm/tools/ls_test.go                          | 457 ------------
internal/tui/components/chat/editor/editor.go          | 142 ++
internal/tui/components/chat/editor/keys.go            |  59 +
internal/tui/components/completions/completions.go     | 195 +++++
internal/tui/components/completions/item.go            | 247 ++++++
internal/tui/components/completions/keys.go            |  53 +
internal/tui/components/core/list/list.go              |  38 
internal/tui/components/dialog/arguments.go            | 252 ------
internal/tui/components/dialog/commands.go             | 182 ----
internal/tui/components/dialog/complete.go             | 264 ------
internal/tui/components/dialog/custom_commands.go      | 185 ----
internal/tui/components/dialog/custom_commands_test.go | 106 --
internal/tui/components/dialogs/commands/commands.go   |   9 
internal/tui/components/dialogs/commands/item.go       | 145 ---
internal/tui/page/chat.go                              | 102 --
internal/tui/tui.go                                    |  69 +
23 files changed, 1,009 insertions(+), 2,068 deletions(-)

Detailed changes

go.mod 🔗

@@ -12,6 +12,7 @@ require (
 	github.com/aymanbagabas/go-udiff v0.2.0
 	github.com/bmatcuk/doublestar/v4 v4.8.1
 	github.com/catppuccin/go v0.3.0
+	github.com/charlievieth/fastwalk v1.0.11
 	github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318
 	github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c
 	github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
@@ -27,6 +28,7 @@ require (
 	github.com/ncruces/go-sqlite3 v0.25.0
 	github.com/openai/openai-go v0.1.0-beta.2
 	github.com/pressly/goose/v3 v3.24.2
+	github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
 	github.com/sahilm/fuzzy v0.1.1
 	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
 	github.com/spf13/cobra v1.9.1
@@ -81,7 +83,6 @@ require (
 	github.com/gorilla/websocket v1.5.3 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/kylelemons/godebug v1.1.0 // indirect
-	github.com/lithammer/fuzzysearch v1.1.8
 	github.com/lucasb-eyer/go-colorful v1.2.0
 	github.com/mattn/go-isatty v0.0.20 // indirect
 	github.com/mattn/go-runewidth v0.0.16 // indirect

go.sum 🔗

@@ -68,6 +68,8 @@ github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR
 github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
 github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
 github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
+github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8=
+github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318 h1:f8Q0ybZGxT+St1JfPM7yoz/XFpbmtodcIehaom/9XT8=
 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c h1:EoW1x1K2EDKYw1D7raqZqWKnwk21IZVpYqLHQVhz1ZU=
@@ -148,8 +150,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
-github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
-github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 github.com/mark3labs/mcp-go v0.17.0 h1:5Ps6T7qXr7De/2QTqs9h6BKeZ/qdeUeGrgM5lPzi930=
@@ -197,6 +197,8 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
+github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
 github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
 github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
 github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
@@ -224,6 +226,7 @@ github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqj
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@@ -348,6 +351,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=

internal/completions/files-folders.go 🔗

@@ -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",
-	}
-}

internal/fileutil/fileutil.go 🔗

@@ -2,7 +2,6 @@ package fileutil
 
 import (
 	"fmt"
-	"io/fs"
 	"os"
 	"os/exec"
 	"path/filepath"
@@ -11,7 +10,9 @@ import (
 	"time"
 
 	"github.com/bmatcuk/doublestar/v4"
+	"github.com/charlievieth/fastwalk"
 	"github.com/opencode-ai/opencode/internal/logging"
+	ignore "github.com/sabhiram/go-gitignore"
 )
 
 var (
@@ -53,21 +54,6 @@ func GetRgCmd(globPattern string) *exec.Cmd {
 	return cmd
 }
 
-func GetFzfCmd(query string) *exec.Cmd {
-	if fzfPath == "" {
-		return nil
-	}
-	fzfArgs := []string{
-		"--filter",
-		query,
-		"--read0",
-		"--print0",
-	}
-	cmd := exec.Command(fzfPath, fzfArgs...)
-	cmd.Dir = "."
-	return cmd
-}
-
 type FileInfo struct {
 	Path    string
 	ModTime time.Time
@@ -112,37 +98,92 @@ func SkipHidden(path string) bool {
 	return false
 }
 
-func GlobWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) {
-	fsys := os.DirFS(searchPath)
-	relPattern := strings.TrimPrefix(pattern, "/")
+// FastGlobWalker provides gitignore-aware file walking with fastwalk
+type FastGlobWalker struct {
+	gitignore *ignore.GitIgnore
+	rootPath  string
+}
+
+func NewFastGlobWalker(searchPath string) *FastGlobWalker {
+	walker := &FastGlobWalker{
+		rootPath: searchPath,
+	}
+
+	// Load gitignore if it exists
+	gitignorePath := filepath.Join(searchPath, ".gitignore")
+	if _, err := os.Stat(gitignorePath); err == nil {
+		if gi, err := ignore.CompileIgnoreFile(gitignorePath); err == nil {
+			walker.gitignore = gi
+		}
+	}
+
+	return walker
+}
+
+func (w *FastGlobWalker) shouldSkip(path string) bool {
+	if SkipHidden(path) {
+		return true
+	}
+
+	if w.gitignore != nil {
+		relPath, err := filepath.Rel(w.rootPath, path)
+		if err == nil && w.gitignore.MatchesPath(relPath) {
+			return true
+		}
+	}
+
+	return false
+}
+
+func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool, error) {
+	walker := NewFastGlobWalker(searchPath)
 	var matches []FileInfo
+	conf := fastwalk.Config{
+		Follow: true,
+		// Use forward slashes when running a Windows binary under WSL or MSYS
+		ToSlash: fastwalk.DefaultToSlash(),
+		Sort:    fastwalk.SortFilesFirst,
+	}
+	err := fastwalk.Walk(&conf, searchPath, func(path string, d os.DirEntry, err error) error {
+		if err != nil {
+			return nil // Skip files we can't access
+		}
 
-	err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
 		if d.IsDir() {
+			if walker.shouldSkip(path) {
+				return filepath.SkipDir
+			}
 			return nil
 		}
-		if SkipHidden(path) {
+
+		if walker.shouldSkip(path) {
 			return nil
 		}
-		info, err := d.Info()
+
+		// Check if path matches the pattern
+		relPath, err := filepath.Rel(searchPath, path)
 		if err != nil {
+			relPath = path
+		}
+
+		matched, err := doublestar.Match(pattern, relPath)
+		if err != nil || !matched {
 			return nil
 		}
-		absPath := path
-		if !strings.HasPrefix(absPath, searchPath) && searchPath != "." {
-			absPath = filepath.Join(searchPath, absPath)
-		} else if !strings.HasPrefix(absPath, "/") && searchPath == "." {
-			absPath = filepath.Join(searchPath, absPath) // Ensure relative paths are joined correctly
+
+		info, err := d.Info()
+		if err != nil {
+			return nil
 		}
 
-		matches = append(matches, FileInfo{Path: absPath, ModTime: info.ModTime()})
+		matches = append(matches, FileInfo{Path: path, ModTime: info.ModTime()})
 		if limit > 0 && len(matches) >= limit*2 {
-			return fs.SkipAll
+			return filepath.SkipAll
 		}
 		return nil
 	})
 	if err != nil {
-		return nil, false, fmt.Errorf("glob walk error: %w", err)
+		return nil, false, fmt.Errorf("fastwalk error: %w", err)
 	}
 
 	sort.Slice(matches, func(i, j int) bool {

internal/fileutil/ls.go 🔗

@@ -0,0 +1,169 @@
+package fileutil
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/charlievieth/fastwalk"
+	ignore "github.com/sabhiram/go-gitignore"
+)
+
+// CommonIgnorePatterns contains commonly ignored files and directories
+var CommonIgnorePatterns = []string{
+	// Version control
+	".git",
+	".svn",
+	".hg",
+	".bzr",
+
+	// IDE and editor files
+	".vscode",
+	".idea",
+	"*.swp",
+	"*.swo",
+	"*~",
+	".DS_Store",
+	"Thumbs.db",
+
+	// Build artifacts and dependencies
+	"node_modules",
+	"target",
+	"build",
+	"dist",
+	"out",
+	"bin",
+	"obj",
+	"*.o",
+	"*.so",
+	"*.dylib",
+	"*.dll",
+	"*.exe",
+
+	// Logs and temporary files
+	"*.log",
+	"*.tmp",
+	"*.temp",
+	".cache",
+	".tmp",
+
+	// Language-specific
+	"__pycache__",
+	"*.pyc",
+	"*.pyo",
+	".pytest_cache",
+	"vendor",
+	"Cargo.lock",
+	"package-lock.json",
+	"yarn.lock",
+	"pnpm-lock.yaml",
+
+	// OS generated files
+	".Trash",
+	".Spotlight-V100",
+	".fseventsd",
+
+	// OpenCode
+	".opencode",
+}
+
+type DirectoryLister struct {
+	gitignore    *ignore.GitIgnore
+	commonIgnore *ignore.GitIgnore
+	rootPath     string
+}
+
+func NewDirectoryLister(rootPath string) *DirectoryLister {
+	dl := &DirectoryLister{
+		rootPath: rootPath,
+	}
+
+	// Load gitignore if it exists
+	gitignorePath := filepath.Join(rootPath, ".gitignore")
+	if _, err := os.Stat(gitignorePath); err == nil {
+		if gi, err := ignore.CompileIgnoreFile(gitignorePath); err == nil {
+			dl.gitignore = gi
+		}
+	}
+
+	// Create common ignore patterns
+	dl.commonIgnore = ignore.CompileIgnoreLines(CommonIgnorePatterns...)
+
+	return dl
+}
+
+func (dl *DirectoryLister) shouldIgnore(path string, ignorePatterns []string) bool {
+	relPath, err := filepath.Rel(dl.rootPath, path)
+	if err != nil {
+		relPath = path
+	}
+
+	// Check common ignore patterns
+	if dl.commonIgnore.MatchesPath(relPath) {
+		return true
+	}
+
+	// Check gitignore patterns if available
+	if dl.gitignore != nil && dl.gitignore.MatchesPath(relPath) {
+		return true
+	}
+
+	base := filepath.Base(path)
+
+	if base != "." && strings.HasPrefix(base, ".") {
+		return true
+	}
+
+	for _, pattern := range ignorePatterns {
+		matched, err := filepath.Match(pattern, base)
+		if err == nil && matched {
+			return true
+		}
+	}
+	return false
+}
+
+// ListDirectory lists files and directories in the specified path,
+func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
+	var results []string
+	truncated := false
+	dl := NewDirectoryLister(initialPath)
+
+	conf := fastwalk.Config{
+		Follow: true,
+		// Use forward slashes when running a Windows binary under WSL or MSYS
+		ToSlash: fastwalk.DefaultToSlash(),
+		Sort:    fastwalk.SortDirsFirst,
+	}
+	err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error {
+		if err != nil {
+			return nil // Skip files we don't have permission to access
+		}
+
+		if dl.shouldIgnore(path, ignorePatterns) {
+			if d.IsDir() {
+				return filepath.SkipDir
+			}
+			return nil
+		}
+
+		if path != initialPath {
+			if d.IsDir() {
+				path = path + string(filepath.Separator)
+			}
+			results = append(results, path)
+		}
+
+		if limit > 0 && len(results) >= limit {
+			truncated = true
+			return filepath.SkipAll
+		}
+
+		return nil
+	})
+	if err != nil {
+		return nil, truncated, err
+	}
+
+	return results, truncated, nil
+}

internal/llm/tools/glob.go 🔗

@@ -137,7 +137,7 @@ func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
 		logging.Warn(fmt.Sprintf("Ripgrep execution failed: %v. Falling back to doublestar.", err))
 	}
 
-	return fileutil.GlobWithDoublestar(pattern, searchPath, limit)
+	return fileutil.GlobWithDoubleStar(pattern, searchPath, limit)
 }
 
 func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) {

internal/llm/tools/ls.go 🔗

@@ -9,6 +9,7 @@ import (
 	"strings"
 
 	"github.com/opencode-ai/opencode/internal/config"
+	"github.com/opencode-ai/opencode/internal/fileutil"
 )
 
 type LSParams struct {
@@ -107,7 +108,7 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
 		return NewTextErrorResponse(fmt.Sprintf("path does not exist: %s", searchPath)), nil
 	}
 
-	files, truncated, err := listDirectory(searchPath, params.Ignore, MaxLSFiles)
+	files, truncated, err := fileutil.ListDirectory(searchPath, params.Ignore, MaxLSFiles)
 	if err != nil {
 		return ToolResponse{}, fmt.Errorf("error listing directory: %w", err)
 	}
@@ -128,101 +129,6 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
 	), nil
 }
 
-func listDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
-	var results []string
-	truncated := false
-
-	err := filepath.Walk(initialPath, func(path string, info os.FileInfo, err error) error {
-		if err != nil {
-			return nil // Skip files we don't have permission to access
-		}
-
-		if shouldSkip(path, ignorePatterns) {
-			if info.IsDir() {
-				return filepath.SkipDir
-			}
-			return nil
-		}
-
-		if path != initialPath {
-			if info.IsDir() {
-				path = path + string(filepath.Separator)
-			}
-			results = append(results, path)
-		}
-
-		if len(results) >= limit {
-			truncated = true
-			return filepath.SkipAll
-		}
-
-		return nil
-	})
-	if err != nil {
-		return nil, truncated, err
-	}
-
-	return results, truncated, nil
-}
-
-func shouldSkip(path string, ignorePatterns []string) bool {
-	base := filepath.Base(path)
-
-	if base != "." && strings.HasPrefix(base, ".") {
-		return true
-	}
-
-	commonIgnored := []string{
-		"__pycache__",
-		"node_modules",
-		"dist",
-		"build",
-		"target",
-		"vendor",
-		"bin",
-		"obj",
-		".git",
-		".idea",
-		".vscode",
-		".DS_Store",
-		"*.pyc",
-		"*.pyo",
-		"*.pyd",
-		"*.so",
-		"*.dll",
-		"*.exe",
-	}
-
-	if strings.Contains(path, filepath.Join("__pycache__", "")) {
-		return true
-	}
-
-	for _, ignored := range commonIgnored {
-		if strings.HasSuffix(ignored, "/") {
-			if strings.Contains(path, filepath.Join(ignored[:len(ignored)-1], "")) {
-				return true
-			}
-		} else if strings.HasPrefix(ignored, "*.") {
-			if strings.HasSuffix(base, ignored[1:]) {
-				return true
-			}
-		} else {
-			if base == ignored {
-				return true
-			}
-		}
-	}
-
-	for _, pattern := range ignorePatterns {
-		matched, err := filepath.Match(pattern, base)
-		if err == nil && matched {
-			return true
-		}
-	}
-
-	return false
-}
-
 func createFileTree(sortedPaths []string) []*TreeNode {
 	root := []*TreeNode{}
 	pathMap := make(map[string]*TreeNode)

internal/llm/tools/ls_test.go 🔗

@@ -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)
-	})
-}

internal/tui/components/chat/editor.go → internal/tui/components/chat/editor/editor.go 🔗

@@ -1,4 +1,4 @@
-package chat
+package editor
 
 import (
 	"fmt"
@@ -13,9 +13,12 @@ import (
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/opencode-ai/opencode/internal/app"
+	"github.com/opencode-ai/opencode/internal/fileutil"
 	"github.com/opencode-ai/opencode/internal/logging"
 	"github.com/opencode-ai/opencode/internal/message"
 	"github.com/opencode-ai/opencode/internal/session"
+	"github.com/opencode-ai/opencode/internal/tui/components/chat"
+	"github.com/opencode-ai/opencode/internal/tui/components/completions"
 	"github.com/opencode-ai/opencode/internal/tui/components/dialog"
 	"github.com/opencode-ai/opencode/internal/tui/layout"
 	"github.com/opencode-ai/opencode/internal/tui/styles"
@@ -23,6 +26,10 @@ import (
 	"github.com/opencode-ai/opencode/internal/tui/util"
 )
 
+type FileCompletionItem struct {
+	Path string // The file path
+}
+
 type editorCmp struct {
 	width       int
 	height      int
@@ -32,35 +39,21 @@ type editorCmp struct {
 	textarea    textarea.Model
 	attachments []message.Attachment
 	deleteMode  bool
-}
 
-type EditorKeyMaps struct {
-	Send       key.Binding
-	OpenEditor key.Binding
-}
+	keyMap EditorKeyMap
 
-type bluredEditorKeyMaps struct {
-	Send       key.Binding
-	Focus      key.Binding
-	OpenEditor key.Binding
+	// File path completions
+	currentQuery          string
+	completionsStartIndex int
+	isCompletionsOpen     bool
 }
+
 type DeleteAttachmentKeyMaps struct {
 	AttachmentDeleteMode key.Binding
 	Escape               key.Binding
 	DeleteAllAttachments key.Binding
 }
 
-var editorMaps = EditorKeyMaps{
-	Send: key.NewBinding(
-		key.WithKeys("enter", "ctrl+s"),
-		key.WithHelp("enter", "send message"),
-	),
-	OpenEditor: key.NewBinding(
-		key.WithKeys("ctrl+e"),
-		key.WithHelp("ctrl+e", "open editor"),
-	),
-}
-
 var DeleteKeyMaps = DeleteAttachmentKeyMaps{
 	AttachmentDeleteMode: key.NewBinding(
 		key.WithKeys("ctrl+r"),
@@ -109,7 +102,7 @@ func (m *editorCmp) openEditor() tea.Cmd {
 		os.Remove(tmpfile.Name())
 		attachments := m.attachments
 		m.attachments = nil
-		return SendMsg{
+		return chat.SendMsg{
 			Text:        string(content),
 			Attachments: attachments,
 		}
@@ -134,7 +127,7 @@ func (m *editorCmp) send() tea.Cmd {
 		return nil
 	}
 	return tea.Batch(
-		util.CmdHandler(SendMsg{
+		util.CmdHandler(chat.SendMsg{
 			Text:        value,
 			Attachments: attachments,
 		}),
@@ -143,16 +136,12 @@ func (m *editorCmp) send() tea.Cmd {
 
 func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmd tea.Cmd
+	var cmds []tea.Cmd
 	switch msg := msg.(type) {
 	case dialog.ThemeChangedMsg:
 		m.textarea = CreateTextArea(&m.textarea)
-	case dialog.CompletionSelectedMsg:
-		existingValue := m.textarea.Value()
-		modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
-
-		m.textarea.SetValue(modifiedValue)
-		return m, nil
-	case SessionSelectedMsg:
+		return m, cmd
+	case chat.SessionSelectedMsg:
 		if msg.ID != m.session.ID {
 			m.session = msg
 		}
@@ -163,7 +152,64 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return m, cmd
 		}
 		m.attachments = append(m.attachments, msg.Attachment)
+		return m, nil
+	case completions.CompletionsClosedMsg:
+		m.isCompletionsOpen = false
+		m.currentQuery = ""
+		m.completionsStartIndex = 0
+	case completions.SelectCompletionMsg:
+		if !m.isCompletionsOpen {
+			return m, nil
+		}
+		if item, ok := msg.Value.(FileCompletionItem); ok {
+			// If the selected item is a file, insert its path into the textarea
+			value := m.textarea.Value()
+			value = value[:m.completionsStartIndex]
+			if len(value) > 0 && value[len(value)-1] != ' ' {
+				value += " "
+			}
+			value += item.Path
+			m.textarea.SetValue(value)
+			m.isCompletionsOpen = false
+			m.currentQuery = ""
+			m.completionsStartIndex = 0
+			return m, nil
+		}
 	case tea.KeyPressMsg:
+		switch {
+		// Completions
+		case msg.String() == "/" && !m.isCompletionsOpen:
+			m.isCompletionsOpen = true
+			m.currentQuery = ""
+			cmds = append(cmds, m.startCompletions)
+			m.completionsStartIndex = len(m.textarea.Value())
+		case msg.String() == "space" && m.isCompletionsOpen:
+			m.isCompletionsOpen = false
+			m.currentQuery = ""
+			m.completionsStartIndex = 0
+			cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
+		case m.isCompletionsOpen && m.textarea.Cursor().X <= m.completionsStartIndex:
+			cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
+		case msg.String() == "backspace" && m.isCompletionsOpen:
+			if len(m.currentQuery) > 0 {
+				m.currentQuery = m.currentQuery[:len(m.currentQuery)-1]
+				cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{
+					Query: m.currentQuery,
+				}))
+			} else {
+				m.isCompletionsOpen = false
+				m.currentQuery = ""
+				m.completionsStartIndex = 0
+				cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
+			}
+		default:
+			if m.isCompletionsOpen {
+				m.currentQuery += msg.String()
+				cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{
+					Query: m.currentQuery,
+				}))
+			}
+		}
 		if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
 			m.deleteMode = true
 			return m, nil
@@ -186,7 +232,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return m, nil
 			}
 		}
-		if key.Matches(msg, editorMaps.OpenEditor) {
+		if key.Matches(msg, m.keyMap.OpenEditor) {
 			if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
 				return m, util.ReportWarn("Agent is working, please wait...")
 			}
@@ -197,7 +243,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return m, nil
 		}
 		// Hanlde Enter key
-		if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
+		if m.textarea.Focused() && key.Matches(msg, m.keyMap.Send) {
 			value := m.textarea.Value()
 			if len(value) > 0 && value[len(value)-1] == '\\' {
 				// If the last character is a backslash, remove it and add a newline
@@ -210,7 +256,8 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 	}
 	m.textarea, cmd = m.textarea.Update(msg)
-	return m, cmd
+	cmds = append(cmds, cmd)
+	return m, tea.Batch(cmds...)
 }
 
 func (m *editorCmp) View() tea.View {
@@ -223,8 +270,8 @@ func (m *editorCmp) View() tea.View {
 		Foreground(t.Primary())
 
 	cursor := m.textarea.Cursor()
-	cursor.X = m.textarea.Cursor().X + m.x + 2
-	cursor.Y = m.textarea.Cursor().Y + m.y + 1
+	cursor.X = cursor.X + m.x + 2
+	cursor.Y = cursor.Y + m.y + 1
 	if len(m.attachments) == 0 {
 		view := tea.NewView(lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View()))
 		view.SetCursor(cursor)
@@ -278,7 +325,7 @@ func (m *editorCmp) attachmentsContent() string {
 
 func (m *editorCmp) BindingKeys() []key.Binding {
 	bindings := []key.Binding{}
-	bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
+	bindings = append(bindings, layout.KeyMapToSlice(m.keyMap)...)
 	bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
 	return bindings
 }
@@ -289,6 +336,28 @@ func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
 	return nil
 }
 
+func (m *editorCmp) startCompletions() tea.Msg {
+	files, _, _ := fileutil.ListDirectory(".", []string{}, 0)
+	completionItems := make([]completions.Completion, 0, len(files))
+	for _, file := range files {
+		file = strings.TrimPrefix(file, "./")
+		completionItems = append(completionItems, completions.Completion{
+			Title: file,
+			Value: FileCompletionItem{
+				Path: file,
+			},
+		})
+	}
+
+	x := m.textarea.Cursor().X + m.x + 1
+	y := m.textarea.Cursor().Y + m.y + 1
+	return completions.OpenCompletionsMsg{
+		Completions: completionItems,
+		X:           x,
+		Y:           y,
+	}
+}
+
 func CreateTextArea(existing *textarea.Model) textarea.Model {
 	t := theme.CurrentTheme()
 	bgColor := t.Background()
@@ -333,5 +402,6 @@ func NewEditorCmp(app *app.App) util.Model {
 	return &editorCmp{
 		app:      app,
 		textarea: ta,
+		keyMap:   DefaultEditorKeyMap(),
 	}
 }

internal/tui/components/chat/editor/keys.go 🔗

@@ -0,0 +1,59 @@
+package editor
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/opencode-ai/opencode/internal/tui/layout"
+)
+
+type EditorKeyMap struct {
+	Send       key.Binding
+	OpenEditor key.Binding
+}
+
+func DefaultEditorKeyMap() EditorKeyMap {
+	return EditorKeyMap{
+		Send: key.NewBinding(
+			key.WithKeys("enter"),
+			key.WithHelp("enter", "send"),
+		),
+		OpenEditor: key.NewBinding(
+			key.WithKeys("ctrl+e"),
+			key.WithHelp("ctrl+e", "open editor"),
+		),
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (k EditorKeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := layout.KeyMapToSlice(k)
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k EditorKeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		k.Send,
+		k.OpenEditor,
+	}
+}
+
+// TODO: update this to use the new keymap concepts
+var AttachmentsKeyMaps = DeleteAttachmentKeyMaps{
+	AttachmentDeleteMode: key.NewBinding(
+		key.WithKeys("ctrl+r"),
+		key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
+	),
+	Escape: key.NewBinding(
+		key.WithKeys("esc"),
+		key.WithHelp("esc", "cancel delete mode"),
+	),
+	DeleteAllAttachments: key.NewBinding(
+		key.WithKeys("r"),
+		key.WithHelp("ctrl+r+r", "delete all attachments"),
+	),
+}

internal/tui/components/completions/completions.go 🔗

@@ -0,0 +1,195 @@
+package completions
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/opencode-ai/opencode/internal/tui/components/core/list"
+	"github.com/opencode-ai/opencode/internal/tui/styles"
+	"github.com/opencode-ai/opencode/internal/tui/theme"
+	"github.com/opencode-ai/opencode/internal/tui/util"
+)
+
+type Completion struct {
+	Title string // The title of the completion item
+	Value any    // The value of the completion item
+}
+
+type OpenCompletionsMsg struct {
+	Completions []Completion
+	X           int // X position for the completions popup
+	Y           int // Y position for the completions popup
+}
+
+type FilterCompletionsMsg struct {
+	Query string // The query to filter completions
+}
+
+type CompletionsClosedMsg struct{}
+
+type CloseCompletionsMsg struct{}
+
+type SelectCompletionMsg struct {
+	Value any // The value of the selected completion item
+}
+
+type Completions interface {
+	util.Model
+	Open() bool
+	Query() string // Returns the current filter query
+	KeyMap() KeyMap
+	Position() (int, int) // Returns the X and Y position of the completions popup
+}
+
+type completionsCmp struct {
+	width  int
+	height int  // Height of the completions component`
+	x      int  // X position for the completions popup\
+	y      int  // Y position for the completions popup
+	open   bool // Indicates if the completions are open
+	keyMap KeyMap
+
+	list  list.ListModel
+	query string // The current filter query
+}
+
+func New() Completions {
+	completionsKeyMap := DefaultKeyMap()
+	keyMap := list.DefaultKeyMap()
+	keyMap.Up.SetEnabled(false)
+	keyMap.Down.SetEnabled(false)
+	keyMap.NDown.SetEnabled(false)
+	keyMap.NUp.SetEnabled(false)
+	keyMap.HalfPageDown.SetEnabled(false)
+	keyMap.HalfPageUp.SetEnabled(false)
+	keyMap.Home.SetEnabled(false)
+	keyMap.End.SetEnabled(false)
+	keyMap.UpOneItem = completionsKeyMap.Up
+	keyMap.DownOneItem = completionsKeyMap.Down
+
+	l := list.New(
+		list.WithReverse(true),
+		list.WithKeyMap(keyMap),
+		list.WithHideFilterInput(true),
+	)
+	return &completionsCmp{
+		width:  30,
+		height: 10,
+		list:   l,
+		query:  "",
+		keyMap: completionsKeyMap,
+	}
+}
+
+// Init implements Completions.
+func (c *completionsCmp) Init() tea.Cmd {
+	return tea.Sequence(
+		c.list.Init(),
+		c.list.SetSize(c.width, c.height),
+	)
+}
+
+// Update implements Completions.
+func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, c.keyMap.Up):
+			u, cmd := c.list.Update(msg)
+			c.list = u.(list.ListModel)
+			return c, cmd
+
+		case key.Matches(msg, c.keyMap.Down):
+			d, cmd := c.list.Update(msg)
+			c.list = d.(list.ListModel)
+			return c, cmd
+		case key.Matches(msg, c.keyMap.Select):
+			selectedItemInx := c.list.SelectedIndex()
+			if selectedItemInx == list.NoSelection {
+				return c, nil // No item selected, do nothing
+			}
+			items := c.list.Items()
+			selectedItem := items[selectedItemInx].(CompletionItem).Value()
+			c.open = false // Close completions after selection
+			return c, util.CmdHandler(SelectCompletionMsg{
+				Value: selectedItem,
+			})
+		case key.Matches(msg, c.keyMap.Cancel):
+			if c.open {
+				c.open = false
+				return c, util.CmdHandler(CompletionsClosedMsg{})
+			}
+		}
+	case CloseCompletionsMsg:
+		c.open = false
+		c.query = ""
+		return c, tea.Batch(
+			c.list.SetItems([]util.Model{}),
+			util.CmdHandler(CompletionsClosedMsg{}),
+		)
+	case OpenCompletionsMsg:
+		c.open = true
+		c.query = ""
+		c.x = msg.X
+		c.y = msg.Y
+		items := []util.Model{}
+		for _, completion := range msg.Completions {
+			item := NewCompletionItem(completion.Title, completion.Value)
+			items = append(items, item)
+		}
+		c.height = max(min(10, len(items)), 1) // Ensure at least 1 item height
+		cmds := []tea.Cmd{
+			c.list.SetSize(c.width, c.height),
+			c.list.SetItems(items),
+		}
+		return c, tea.Batch(cmds...)
+	case FilterCompletionsMsg:
+		c.query = msg.Query
+		if !c.open {
+			return c, nil // If completions are not open, do nothing
+		}
+		cmd := c.list.Filter(msg.Query)
+		c.height = max(min(10, len(c.list.Items())), 1)
+		return c, tea.Batch(
+			cmd,
+			c.list.SetSize(c.width, c.height),
+		)
+	}
+	return c, nil
+}
+
+// View implements Completions.
+func (c *completionsCmp) View() tea.View {
+	if len(c.list.Items()) == 0 {
+		return tea.NewView(c.style().Render("No completions found"))
+	}
+
+	view := tea.NewView(
+		c.style().Render(c.list.View().String()),
+	)
+	return view
+}
+
+func (c *completionsCmp) style() lipgloss.Style {
+	t := theme.CurrentTheme()
+	return styles.BaseStyle().
+		Width(c.width).
+		Height(c.height).
+		Background(t.BackgroundSecondary())
+}
+
+func (c *completionsCmp) Open() bool {
+	return c.open
+}
+
+func (c *completionsCmp) Query() string {
+	return c.query
+}
+
+func (c *completionsCmp) KeyMap() KeyMap {
+	return c.keyMap
+}
+
+func (c *completionsCmp) Position() (int, int) {
+	return c.x, c.y - c.height
+}

internal/tui/components/completions/item.go 🔗

@@ -0,0 +1,247 @@
+package completions
+
+import (
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/opencode-ai/opencode/internal/tui/components/core/list"
+	"github.com/opencode-ai/opencode/internal/tui/layout"
+	"github.com/opencode-ai/opencode/internal/tui/styles"
+	"github.com/opencode-ai/opencode/internal/tui/theme"
+	"github.com/opencode-ai/opencode/internal/tui/util"
+	"github.com/rivo/uniseg"
+)
+
+type CompletionItem interface {
+	util.Model
+	layout.Focusable
+	layout.Sizeable
+	list.HasMatchIndexes
+	list.HasFilterValue
+	Value() any
+}
+
+type completionItemCmp struct {
+	width        int
+	text         string
+	value        any
+	focus        bool
+	matchIndexes []int
+}
+
+func NewCompletionItem(text string, value any, matchIndexes ...int) CompletionItem {
+	return &completionItemCmp{
+		text:         text,
+		value:        value,
+		matchIndexes: matchIndexes,
+	}
+}
+
+// Init implements CommandItem.
+func (c *completionItemCmp) Init() tea.Cmd {
+	return nil
+}
+
+// Update implements CommandItem.
+func (c *completionItemCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
+	return c, nil
+}
+
+// View implements CommandItem.
+func (c *completionItemCmp) View() tea.View {
+	t := theme.CurrentTheme()
+
+	baseStyle := styles.BaseStyle().Background(t.BackgroundSecondary())
+	titleStyle := baseStyle.Padding(0, 1).Width(c.width).Foreground(t.Text())
+	titleMatchStyle := baseStyle.Foreground(t.Text()).Underline(true)
+
+	if c.focus {
+		titleStyle = titleStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true)
+		titleMatchStyle = titleMatchStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true)
+	}
+
+	var truncatedTitle string
+	var adjustedMatchIndexes []int
+
+	availableWidth := c.width - 2 // Account for padding
+	if len(c.matchIndexes) > 0 && len(c.text) > availableWidth {
+		// Smart truncation: ensure the last matching part is visible
+		truncatedTitle, adjustedMatchIndexes = c.smartTruncate(c.text, availableWidth, c.matchIndexes)
+	} else {
+		// No matches, use regular truncation
+		truncatedTitle = ansi.Truncate(c.text, availableWidth, "…")
+		adjustedMatchIndexes = c.matchIndexes
+	}
+
+	text := titleStyle.Render(truncatedTitle)
+	if len(adjustedMatchIndexes) > 0 {
+		var ranges []lipgloss.Range
+		for _, rng := range matchedRanges(adjustedMatchIndexes) {
+			// ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
+			// all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
+			// so we need to adjust it here:
+			start, stop := bytePosToVisibleCharPos(text, rng)
+			ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
+		}
+		text = lipgloss.StyleRanges(text, ranges...)
+	}
+	return tea.NewView(text)
+}
+
+// Blur implements CommandItem.
+func (c *completionItemCmp) Blur() tea.Cmd {
+	c.focus = false
+	return nil
+}
+
+// Focus implements CommandItem.
+func (c *completionItemCmp) Focus() tea.Cmd {
+	c.focus = true
+	return nil
+}
+
+// GetSize implements CommandItem.
+func (c *completionItemCmp) GetSize() (int, int) {
+	return c.width, 1
+}
+
+// IsFocused implements CommandItem.
+func (c *completionItemCmp) IsFocused() bool {
+	return c.focus
+}
+
+// SetSize implements CommandItem.
+func (c *completionItemCmp) SetSize(width int, height int) tea.Cmd {
+	c.width = width
+	return nil
+}
+
+func (c *completionItemCmp) MatchIndexes(indexes []int) {
+	c.matchIndexes = indexes
+	for i := range c.matchIndexes {
+		c.matchIndexes[i] += 1 // Adjust for the padding we add in View
+	}
+}
+
+func (c *completionItemCmp) FilterValue() string {
+	return c.text
+}
+
+func (c *completionItemCmp) Value() any {
+	return c.value
+}
+
+// smartTruncate implements fzf-style truncation that ensures the last matching part is visible
+func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes []int) (string, []int) {
+	if width <= 0 {
+		return "", []int{}
+	}
+
+	textLen := ansi.StringWidth(text)
+	if textLen <= width {
+		return text, matchIndexes
+	}
+
+	if len(matchIndexes) == 0 {
+		return ansi.Truncate(text, width, "…"), []int{}
+	}
+
+	// Find the last match position
+	lastMatchPos := matchIndexes[len(matchIndexes)-1]
+
+	// Convert byte position to visual width position
+	lastMatchVisualPos := 0
+	bytePos := 0
+	gr := uniseg.NewGraphemes(text)
+	for bytePos < lastMatchPos && gr.Next() {
+		bytePos += len(gr.Str())
+		lastMatchVisualPos += max(1, gr.Width())
+	}
+
+	// Calculate how much space we need for the ellipsis
+	ellipsisWidth := 1 // "…" character width
+	availableWidth := width - ellipsisWidth
+
+	// If the last match is within the available width, truncate from the end
+	if lastMatchVisualPos < availableWidth {
+		return ansi.Truncate(text, width, "…"), matchIndexes
+	}
+
+	// Calculate the start position to ensure the last match is visible
+	// We want to show some context before the last match if possible
+	startVisualPos := max(0, lastMatchVisualPos-availableWidth+1)
+
+	// Convert visual position back to byte position
+	startBytePos := 0
+	currentVisualPos := 0
+	gr = uniseg.NewGraphemes(text)
+	for currentVisualPos < startVisualPos && gr.Next() {
+		startBytePos += len(gr.Str())
+		currentVisualPos += max(1, gr.Width())
+	}
+
+	// Extract the substring starting from startBytePos
+	truncatedText := text[startBytePos:]
+
+	// Truncate to fit width with ellipsis
+	truncatedText = ansi.Truncate(truncatedText, availableWidth, "")
+	truncatedText = "…" + truncatedText
+
+	// Adjust match indexes for the new truncated string
+	adjustedIndexes := []int{}
+	for _, idx := range matchIndexes {
+		if idx >= startBytePos {
+			newIdx := idx - startBytePos + 1 //
+			// Check if this match is still within the truncated string
+			if newIdx < len(truncatedText) {
+				adjustedIndexes = append(adjustedIndexes, newIdx)
+			}
+		}
+	}
+
+	return truncatedText, adjustedIndexes
+}
+
+func matchedRanges(in []int) [][2]int {
+	if len(in) == 0 {
+		return [][2]int{}
+	}
+	current := [2]int{in[0], in[0]}
+	if len(in) == 1 {
+		return [][2]int{current}
+	}
+	var out [][2]int
+	for i := 1; i < len(in); i++ {
+		if in[i] == current[1]+1 {
+			current[1] = in[i]
+		} else {
+			out = append(out, current)
+			current = [2]int{in[i], in[i]}
+		}
+	}
+	out = append(out, current)
+	return out
+}
+
+func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
+	bytePos, byteStart, byteStop := 0, rng[0], rng[1]
+	pos, start, stop := 0, 0, 0
+	gr := uniseg.NewGraphemes(str)
+	for byteStart > bytePos {
+		if !gr.Next() {
+			break
+		}
+		bytePos += len(gr.Str())
+		pos += max(1, gr.Width())
+	}
+	start = pos
+	for byteStop > bytePos {
+		if !gr.Next() {
+			break
+		}
+		bytePos += len(gr.Str())
+		pos += max(1, gr.Width())
+	}
+	stop = pos
+	return start, stop
+}

internal/tui/components/completions/keys.go 🔗

@@ -0,0 +1,53 @@
+package completions
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/opencode-ai/opencode/internal/tui/layout"
+)
+
+type KeyMap struct {
+	Down,
+	Up,
+	Select,
+	Cancel key.Binding
+}
+
+func DefaultKeyMap() KeyMap {
+	return KeyMap{
+		Down: key.NewBinding(
+			key.WithKeys("down"),
+			key.WithHelp("down", "move down"),
+		),
+		Up: key.NewBinding(
+			key.WithKeys("up"),
+			key.WithHelp("up", "move up"),
+		),
+		Select: key.NewBinding(
+			key.WithKeys("enter"),
+			key.WithHelp("enter", "select"),
+		),
+		Cancel: key.NewBinding(
+			key.WithKeys("esc"),
+			key.WithHelp("esc", "cancel"),
+		),
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (k KeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := layout.KeyMapToSlice(k)
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k KeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		k.Up,
+		k.Down,
+	}
+}

internal/tui/components/core/list/list.go 🔗

@@ -39,6 +39,7 @@ type ListModel interface {
 	ResetView()                     // Clear rendering cache and reset scroll position
 	Items() []util.Model            // Get all items in the list
 	SelectedIndex() int             // Get the index of the currently selected item
+	Filter(string) tea.Cmd          // Filter items based on a search term
 }
 
 // HasAnim interface identifies items that support animation.
@@ -50,13 +51,11 @@ type HasAnim interface {
 
 // HasFilterValue interface allows items to provide a filter value for searching.
 type HasFilterValue interface {
-	util.Model
 	FilterValue() string // Returns a string value used for filtering/searching
 }
 
 // HasMatchIndexes interface allows items to set matched character indexes.
 type HasMatchIndexes interface {
-	util.Model
 	MatchIndexes([]int) // Sets the indexes of matched characters in the item's content
 }
 
@@ -134,10 +133,11 @@ type model struct {
 	gapSize        int            // Number of empty lines between items
 	padding        []int          // Padding around the list content
 
-	filterable    bool            // Whether items can be filtered
-	filteredItems []util.Model    // Filtered items based on current search
-	input         textinput.Model // Input field for filtering items
-	currentSearch string          // Current search term for filtering
+	filterable      bool            // Whether items can be filtered
+	filteredItems   []util.Model    // Filtered items based on current search
+	input           textinput.Model // Input field for filtering items
+	hideFilterInput bool            // Whether to hide the filter input field
+	currentSearch   string          // Current search term for filtering
 }
 
 // listOptions is a function type for configuring list options.
@@ -188,6 +188,13 @@ func WithFilterable(filterable bool) listOptions {
 	}
 }
 
+// WithHideFilterInput hides the filter input field.
+func WithHideFilterInput(hide bool) listOptions {
+	return func(m *model) {
+		m.hideFilterInput = hide
+	}
+}
+
 // New creates a new list model with the specified options.
 // The list starts with no items selected and requires SetItems to be called
 // or items to be provided via WithItems option.
@@ -206,7 +213,7 @@ func New(opts ...listOptions) ListModel {
 		opt(m)
 	}
 
-	if m.filterable {
+	if m.filterable && !m.hideFilterInput {
 		ti := textinput.New()
 		ti.Placeholder = "Type to filter..."
 		ti.SetVirtualCursor(false)
@@ -259,7 +266,7 @@ func (m *model) View() tea.View {
 		Height(m.viewState.height).
 		Render(m.viewState.content)
 
-	if m.filterable {
+	if m.filterable && !m.hideFilterInput {
 		content = lipgloss.JoinVertical(
 			lipgloss.Left,
 			m.inputStyle().Render(m.input.View()),
@@ -267,7 +274,7 @@ func (m *model) View() tea.View {
 		)
 	}
 	view := tea.NewView(content)
-	if m.filterable {
+	if m.filterable && !m.hideFilterInput {
 		view.SetCursor(m.input.Cursor())
 	}
 	return view
@@ -294,15 +301,15 @@ func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 	case key.Matches(msg, m.keyMap.End):
 		return m, m.goToBottom()
 	default:
-		if !m.filterable {
-			return m, nil // Ignore other keys if not filterable
+		if !m.filterable || m.hideFilterInput {
+			return m, nil // Ignore other keys if not filterable or input is hidden
 		}
 		var cmds []tea.Cmd
 		u, cmd := m.input.Update(msg)
 		m.input = u
 		cmds = append(cmds, cmd)
 		if m.currentSearch != m.input.Value() {
-			cmd = m.filter(m.input.Value())
+			cmd = m.Filter(m.input.Value())
 			cmds = append(cmds, cmd)
 		}
 		m.currentSearch = m.input.Value()
@@ -923,7 +930,7 @@ func (m *model) GetSize() (int, int) {
 // SetSize updates the list dimensions and triggers a complete re-render.
 // Also updates the size of all items that support sizing.
 func (m *model) SetSize(width int, height int) tea.Cmd {
-	if m.filterable {
+	if m.filterable && !m.hideFilterInput {
 		height -= 2 // adjust for input field height and border
 	}
 
@@ -936,7 +943,7 @@ func (m *model) SetSize(width int, height int) tea.Cmd {
 	}
 	m.viewState.width = width
 	m.ResetView()
-	if m.filterable {
+	if m.filterable && !m.hideFilterInput {
 		m.input.SetWidth(m.getItemWidth() - 3)
 	}
 	return m.setAllItemsSize()
@@ -1152,7 +1159,7 @@ func (m *model) flattenSections(sections []section) []util.Model {
 	return result
 }
 
-func (m *model) filter(search string) tea.Cmd {
+func (m *model) Filter(search string) tea.Cmd {
 	var cmds []tea.Cmd
 	search = strings.TrimSpace(search)
 	search = strings.ToLower(search)
@@ -1189,6 +1196,7 @@ func (m *model) filter(search string) tea.Cmd {
 	// Set initial selection
 	if len(m.filteredItems) > 0 {
 		if m.viewState.reverse {
+			slices.Reverse(m.filteredItems)
 			m.selectionState.selectedIndex = m.findLastSelectableItem()
 		} else {
 			m.selectionState.selectedIndex = m.findFirstSelectableItem()

internal/tui/components/dialog/arguments.go 🔗

@@ -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()
-}

internal/tui/components/dialog/commands.go 🔗

@@ -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,
-	}
-}

internal/tui/components/dialog/complete.go 🔗

@@ -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,
-	}
-}

internal/tui/components/dialog/custom_commands.go 🔗

@@ -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
-}

internal/tui/components/dialog/custom_commands_test.go 🔗

@@ -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)
-		}
-	}
-}

internal/tui/components/dialogs/commands/commands.go 🔗

@@ -6,6 +6,7 @@ import (
 	"github.com/charmbracelet/lipgloss/v2"
 
 	"github.com/opencode-ai/opencode/internal/tui/components/chat"
+	"github.com/opencode-ai/opencode/internal/tui/components/completions"
 	"github.com/opencode-ai/opencode/internal/tui/components/core/list"
 	"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
 	"github.com/opencode-ai/opencode/internal/tui/styles"
@@ -75,9 +76,9 @@ func (c *commandDialogCmp) Init() tea.Cmd {
 
 	commandItems := []util.Model{}
 	if len(commands) > 0 {
-		commandItems = append(commandItems, NewItemSection("Custom"))
+		commandItems = append(commandItems, NewItemSection("Custom Commands"))
 		for _, cmd := range commands {
-			commandItems = append(commandItems, NewCommandItem(cmd))
+			commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd))
 		}
 	}
 
@@ -85,7 +86,7 @@ func (c *commandDialogCmp) Init() tea.Cmd {
 
 	for _, cmd := range c.defaultCommands() {
 		c.commands = append(c.commands, cmd)
-		commandItems = append(commandItems, NewCommandItem(cmd))
+		commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd))
 	}
 
 	c.commandList.SetItems(commandItems)
@@ -106,7 +107,7 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return c, nil // No item selected, do nothing
 			}
 			items := c.commandList.Items()
-			selectedItem := items[selectedItemInx].(CommandItem).Command()
+			selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(Command)
 			return c, tea.Sequence(
 				util.CmdHandler(dialogs.CloseDialogMsg{}),
 				selectedItem.Handler(selectedItem),

internal/tui/components/dialogs/commands/item.go 🔗

@@ -11,153 +11,8 @@ import (
 	"github.com/opencode-ai/opencode/internal/tui/styles"
 	"github.com/opencode-ai/opencode/internal/tui/theme"
 	"github.com/opencode-ai/opencode/internal/tui/util"
-	"github.com/rivo/uniseg"
 )
 
-type CommandItem interface {
-	util.Model
-	layout.Focusable
-	layout.Sizeable
-	Command() Command
-}
-
-type commandItem struct {
-	width        int
-	command      Command
-	focus        bool
-	matchIndexes []int
-}
-
-func NewCommandItem(command Command) CommandItem {
-	return &commandItem{
-		command:      command,
-		matchIndexes: make([]int, 0),
-	}
-}
-
-// Init implements CommandItem.
-func (c *commandItem) Init() tea.Cmd {
-	return nil
-}
-
-// Update implements CommandItem.
-func (c *commandItem) Update(tea.Msg) (tea.Model, tea.Cmd) {
-	return c, nil
-}
-
-// View implements CommandItem.
-func (c *commandItem) View() tea.View {
-	t := theme.CurrentTheme()
-
-	baseStyle := styles.BaseStyle()
-	titleStyle := baseStyle.Width(c.width).Foreground(t.Text())
-	titleMatchStyle := baseStyle.Foreground(t.Text()).Underline(true)
-
-	if c.focus {
-		titleStyle = titleStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true)
-		titleMatchStyle = titleMatchStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true)
-	}
-	var ranges []lipgloss.Range
-	truncatedTitle := ansi.Truncate(c.command.Title, c.width, "…")
-	text := titleStyle.Render(truncatedTitle)
-	if len(c.matchIndexes) > 0 {
-		for _, rng := range matchedRanges(c.matchIndexes) {
-			// ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
-			// all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
-			// so we need to adjust it here:
-			start, stop := bytePosToVisibleCharPos(text, rng)
-			ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
-		}
-		text = lipgloss.StyleRanges(text, ranges...)
-	}
-	return tea.NewView(text)
-}
-
-// Command implements CommandItem.
-func (c *commandItem) Command() Command {
-	return c.command
-}
-
-// Blur implements CommandItem.
-func (c *commandItem) Blur() tea.Cmd {
-	c.focus = false
-	return nil
-}
-
-// Focus implements CommandItem.
-func (c *commandItem) Focus() tea.Cmd {
-	c.focus = true
-	return nil
-}
-
-// IsFocused implements CommandItem.
-func (c *commandItem) IsFocused() bool {
-	return c.focus
-}
-
-// GetSize implements CommandItem.
-func (c *commandItem) GetSize() (int, int) {
-	return c.width, 2
-}
-
-// SetSize implements CommandItem.
-func (c *commandItem) SetSize(width int, height int) tea.Cmd {
-	c.width = width
-	return nil
-}
-
-func (c *commandItem) FilterValue() string {
-	return c.command.Title
-}
-
-func (c *commandItem) MatchIndexes(indexes []int) {
-	c.matchIndexes = indexes
-}
-
-func matchedRanges(in []int) [][2]int {
-	if len(in) == 0 {
-		return [][2]int{}
-	}
-	current := [2]int{in[0], in[0]}
-	if len(in) == 1 {
-		return [][2]int{current}
-	}
-	var out [][2]int
-	for i := 1; i < len(in); i++ {
-		if in[i] == current[1]+1 {
-			current[1] = in[i]
-		} else {
-			out = append(out, current)
-			current = [2]int{in[i], in[i]}
-		}
-	}
-	out = append(out, current)
-	return out
-}
-
-func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
-	bytePos, byteStart, byteStop := 0, rng[0], rng[1]
-	pos, start, stop := 0, 0, 0
-	gr := uniseg.NewGraphemes(str)
-	for byteStart > bytePos {
-		if !gr.Next() {
-			break
-		}
-		bytePos += len(gr.Str())
-		pos += max(1, gr.Width())
-	}
-	start = pos
-	for byteStop > bytePos {
-		if !gr.Next() {
-			break
-		}
-		bytePos += len(gr.Str())
-		pos += max(1, gr.Width())
-	}
-	stop = pos
-	return start, stop
-}
-
 type ItemSection interface {
 	util.Model
 	layout.Sizeable

internal/tui/page/chat.go 🔗

@@ -2,18 +2,16 @@ package page
 
 import (
 	"context"
-	"strings"
 
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/opencode-ai/opencode/internal/app"
-	"github.com/opencode-ai/opencode/internal/completions"
 	"github.com/opencode-ai/opencode/internal/logging"
 	"github.com/opencode-ai/opencode/internal/message"
 	"github.com/opencode-ai/opencode/internal/session"
 	"github.com/opencode-ai/opencode/internal/tui/components/chat"
-	"github.com/opencode-ai/opencode/internal/tui/components/dialog"
+	"github.com/opencode-ai/opencode/internal/tui/components/chat/editor"
+	"github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
 	"github.com/opencode-ai/opencode/internal/tui/layout"
 	"github.com/opencode-ai/opencode/internal/tui/util"
 )
@@ -21,26 +19,19 @@ import (
 var ChatPage PageID = "chat"
 
 type chatPage struct {
-	app                  *app.App
-	editor               layout.Container
-	messages             layout.Container
-	layout               layout.SplitPaneLayout
-	session              session.Session
-	completionDialog     dialog.CompletionDialog
-	showCompletionDialog bool
+	app      *app.App
+	editor   layout.Container
+	messages layout.Container
+	layout   layout.SplitPaneLayout
+	session  session.Session
 }
 
 type ChatKeyMap struct {
-	ShowCompletionDialog key.Binding
-	NewSession           key.Binding
-	Cancel               key.Binding
+	NewSession key.Binding
+	Cancel     key.Binding
 }
 
 var keyMap = ChatKeyMap{
-	ShowCompletionDialog: key.NewBinding(
-		key.WithKeys("@"),
-		key.WithHelp("@", "Complete"),
-	),
 	NewSession: key.NewBinding(
 		key.WithKeys("ctrl+n"),
 		key.WithHelp("ctrl+n", "new session"),
@@ -52,11 +43,7 @@ var keyMap = ChatKeyMap{
 }
 
 func (p *chatPage) Init() tea.Cmd {
-	cmds := []tea.Cmd{
-		p.layout.Init(),
-		p.completionDialog.Init(),
-	}
-	return tea.Batch(cmds...)
+	return p.layout.Init()
 }
 
 func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -66,31 +53,19 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		logging.Info("Window size changed Chat:", "Width", msg.Width, "Height", msg.Height)
 		cmd := p.layout.SetSize(msg.Width, msg.Height)
 		cmds = append(cmds, cmd)
-	case dialog.CompletionDialogCloseMsg:
-		p.showCompletionDialog = false
 	case chat.SendMsg:
 		cmd := p.sendMessage(msg.Text, msg.Attachments)
 		if cmd != nil {
 			return p, cmd
 		}
-	case dialog.CommandRunCustomMsg:
+	case commands.CommandRunCustomMsg:
 		// Check if the agent is busy before executing custom commands
 		if p.app.CoderAgent.IsBusy() {
 			return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
 		}
 
-		// Process the command content with arguments if any
-		content := msg.Content
-		if msg.Args != nil {
-			// Replace all named arguments with their values
-			for name, value := range msg.Args {
-				placeholder := "$" + name
-				content = strings.ReplaceAll(content, placeholder, value)
-			}
-		}
-
 		// Handle custom command execution
-		cmd := p.sendMessage(content, nil)
+		cmd := p.sendMessage(msg.Content, nil)
 		if cmd != nil {
 			return p, cmd
 		}
@@ -104,9 +79,6 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		p.session = msg
 	case tea.KeyPressMsg:
 		switch {
-		case key.Matches(msg, keyMap.ShowCompletionDialog):
-			p.showCompletionDialog = true
-			// Continue sending keys to layout->chat
 		case key.Matches(msg, keyMap.NewSession):
 			p.session = session.Session{}
 			return p, tea.Batch(
@@ -122,19 +94,6 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 		}
 	}
-	if p.showCompletionDialog {
-		context, contextCmd := p.completionDialog.Update(msg)
-		p.completionDialog = context.(dialog.CompletionDialog)
-		cmds = append(cmds, contextCmd)
-
-		// Doesn't forward event if enter key is pressed
-		if keyMsg, ok := msg.(tea.KeyPressMsg); ok {
-			if keyMsg.String() == "enter" {
-				return p, tea.Batch(cmds...)
-			}
-		}
-	}
-
 	u, cmd := p.layout.Update(msg)
 	cmds = append(cmds, cmd)
 	p.layout = u.(layout.SplitPaneLayout)
@@ -186,30 +145,7 @@ func (p *chatPage) GetSize() (int, int) {
 }
 
 func (p *chatPage) View() tea.View {
-	layoutView := p.layout.View()
-
-	if p.showCompletionDialog {
-		_, layoutHeight := p.layout.GetSize()
-		editorWidth, editorHeight := p.editor.GetSize()
-
-		p.completionDialog.SetWidth(editorWidth)
-		overlay := p.completionDialog.View()
-
-		viewStr := layout.PlaceOverlay(
-			0,
-			layoutHeight-editorHeight-lipgloss.Height(overlay.String()),
-			overlay.String(),
-			layoutView.String(),
-			false,
-		)
-
-		view := tea.NewView(viewStr)
-		view.SetCursor(overlay.Cursor())
-		return view
-	}
-
-	logging.Info("Cursor in page", "c", layoutView.Cursor())
-	return layoutView
+	return p.layout.View()
 }
 
 func (p *chatPage) BindingKeys() []key.Binding {
@@ -220,22 +156,18 @@ func (p *chatPage) BindingKeys() []key.Binding {
 }
 
 func NewChatPage(app *app.App) util.Model {
-	cg := completions.NewFileAndFolderContextGroup()
-	completionDialog := dialog.NewCompletionDialogCmp(cg)
-
 	messagesContainer := layout.NewContainer(
 		chat.NewMessagesListCmp(app),
 		layout.WithPadding(1, 1, 0, 1),
 	)
 	editorContainer := layout.NewContainer(
-		chat.NewEditorCmp(app),
+		editor.NewEditorCmp(app),
 		layout.WithBorder(true, false, false, false),
 	)
 	return &chatPage{
-		app:              app,
-		editor:           editorContainer,
-		messages:         messagesContainer,
-		completionDialog: completionDialog,
+		app:      app,
+		editor:   editorContainer,
+		messages: messagesContainer,
 		layout: layout.NewSplitPane(
 			layout.WithLeftPanel(messagesContainer),
 			layout.WithBottomPanel(editorContainer),

internal/tui/tui.go 🔗

@@ -7,6 +7,7 @@ import (
 	"github.com/opencode-ai/opencode/internal/app"
 	"github.com/opencode-ai/opencode/internal/logging"
 	"github.com/opencode-ai/opencode/internal/pubsub"
+	"github.com/opencode-ai/opencode/internal/tui/components/completions"
 	"github.com/opencode-ai/opencode/internal/tui/components/core"
 	"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
 	"github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
@@ -30,7 +31,8 @@ type appModel struct {
 
 	app *app.App
 
-	dialog dialogs.DialogCmp
+	dialog      dialogs.DialogCmp
+	completions completions.Completions
 }
 
 func (a appModel) Init() tea.Cmd {
@@ -52,6 +54,12 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.WindowSizeMsg:
 		return a, a.handleWindowResize(msg)
 
+	// Completions messages
+	case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg, completions.CloseCompletionsMsg:
+		u, completionCmd := a.completions.Update(msg)
+		a.completions = u.(completions.Completions)
+		return a, completionCmd
+
 	// Dialog messages
 	case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
 		u, dialogCmd := a.dialog.Update(msg)
@@ -128,6 +136,24 @@ func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
 
 func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 	switch {
+	// completions
+	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Up):
+		u, cmd := a.completions.Update(msg)
+		a.completions = u.(completions.Completions)
+		return cmd
+
+	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Down):
+		u, cmd := a.completions.Update(msg)
+		a.completions = u.(completions.Completions)
+		return cmd
+	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Select):
+		u, cmd := a.completions.Update(msg)
+		a.completions = u.(completions.Completions)
+		return cmd
+	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Cancel):
+		u, cmd := a.completions.Update(msg)
+		a.completions = u.(completions.Completions)
+		return cmd
 	// dialogs
 	case key.Matches(msg, a.keyMap.Quit):
 		return util.CmdHandler(dialogs.OpenDialogMsg{
@@ -191,28 +217,38 @@ func (a *appModel) View() tea.View {
 	components = append(components, a.status.View().String())
 
 	appView := lipgloss.JoinVertical(lipgloss.Top, components...)
-
+	layers := []*lipgloss.Layer{
+		lipgloss.NewLayer(appView),
+	}
 	t := theme.CurrentTheme()
 	if a.dialog.HasDialogs() {
-		layers := append(
-			[]*lipgloss.Layer{
-				lipgloss.NewLayer(appView),
-			},
+		layers = append(
+			layers,
 			a.dialog.GetLayers()...,
 		)
-		canvas := lipgloss.NewCanvas(
-			layers...,
+	}
+
+	cursor := pageView.Cursor()
+	activeView := a.dialog.ActiveView()
+	if activeView != nil {
+		cursor = activeView.Cursor()
+	}
+
+	if a.completions.Open() && cursor != nil {
+		cmp := a.completions.View().String()
+		x, y := a.completions.Position()
+		layers = append(
+			layers,
+			lipgloss.NewLayer(cmp).X(x).Y(y),
 		)
-		view := tea.NewView(canvas.Render())
-		activeView := a.dialog.ActiveView()
-		view.SetBackgroundColor(t.Background())
-		view.SetCursor(activeView.Cursor())
-		return view
 	}
 
-	view := tea.NewView(appView)
-	view.SetCursor(pageView.Cursor())
+	canvas := lipgloss.NewCanvas(
+		layers...,
+	)
+	view := tea.NewView(canvas.Render())
 	view.SetBackgroundColor(t.Background())
+	view.SetCursor(cursor)
 	return view
 }
 
@@ -230,7 +266,8 @@ func New(app *app.App) tea.Model {
 			page.LogsPage: page.NewLogsPage(),
 		},
 
-		dialog: dialogs.NewDialogCmp(),
+		dialog:      dialogs.NewDialogCmp(),
+		completions: completions.New(),
 	}
 
 	return model