Add completion menu for file paths (#145)

Adictya created

* feat(context-dialog): init

* chore(simple-list): refactor with generics

* fix(complete-module): fix fzf issues

* fix(complete-module): add fallbacks when rg or fzf is not available

* chore(complete-module): code improvements

* chore(complete-module): cleanup

* fix(complete-module): dialog keys cleanup

* fix(simple-list): add fallback message

* fix(commands-dialog): refactor to use simple-list

* fix(simple-list): add j and k keys

* fix(complete-module): cleanup and minor bug fixes

* fix(complete-module): self review

* fix(complete-module): remove old file

Change summary

go.mod                                      |   5 
go.sum                                      |   2 
internal/completions/files-folders.go       | 191 ++++++++++++++++
internal/fileutil/fileutil.go               | 163 ++++++++++++++
internal/llm/tools/glob.go                  | 161 +------------
internal/llm/tools/grep.go                  |   3 
internal/tui/components/chat/editor.go      |   8 
internal/tui/components/dialog/commands.go  | 191 +++++-----------
internal/tui/components/dialog/complete.go  | 264 +++++++++++++++++++++++
internal/tui/components/util/simple-list.go | 159 +++++++++++++
internal/tui/page/chat.go                   |  73 +++++
11 files changed, 933 insertions(+), 287 deletions(-)

Detailed changes

go.mod 🔗

@@ -35,8 +35,6 @@ require (
 
 require (
 	cloud.google.com/go v0.116.0 // indirect
-	github.com/google/go-cmp v0.7.0 // indirect
-	github.com/gorilla/websocket v1.5.3 // indirect
 	cloud.google.com/go/auth v0.13.0 // indirect
 	cloud.google.com/go/compute/metadata v0.6.0 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
@@ -72,12 +70,15 @@ require (
 	github.com/go-logr/stdr v1.2.2 // indirect
 	github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
 	github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
+	github.com/google/go-cmp v0.7.0 // indirect
 	github.com/google/s2a-go v0.1.8 // indirect
 	github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
 	github.com/googleapis/gax-go/v2 v2.14.1 // indirect
 	github.com/gorilla/css v1.0.1 // indirect
+	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-localereader v0.0.1 // indirect

go.sum 🔗

@@ -144,6 +144,8 @@ 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/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms=
 github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231/go.mod h1:S5etECMx+sZnW0Gm100Ma9J1PgVCTgNyFaqGu2b08b4=
 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=

internal/completions/files-folders.go 🔗

@@ -0,0 +1,191 @@
+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 🔗

@@ -0,0 +1,163 @@
+package fileutil
+
+import (
+	"fmt"
+	"io/fs"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"sort"
+	"strings"
+	"time"
+
+	"github.com/bmatcuk/doublestar/v4"
+	"github.com/opencode-ai/opencode/internal/logging"
+)
+
+var (
+	rgPath  string
+	fzfPath string
+)
+
+func init() {
+	var err error
+	rgPath, err = exec.LookPath("rg")
+	if err != nil {
+		logging.Warn("Ripgrep (rg) not found in $PATH. Some features might be limited or slower.")
+		rgPath = ""
+	}
+	fzfPath, err = exec.LookPath("fzf")
+	if err != nil {
+		logging.Warn("FZF not found in $PATH. Some features might be limited or slower.")
+		fzfPath = ""
+	}
+}
+
+func GetRgCmd(globPattern string) *exec.Cmd {
+	if rgPath == "" {
+		return nil
+	}
+	rgArgs := []string{
+		"--files",
+		"-L",
+		"--null",
+	}
+	if globPattern != "" {
+		if !filepath.IsAbs(globPattern) && !strings.HasPrefix(globPattern, "/") {
+			globPattern = "/" + globPattern
+		}
+		rgArgs = append(rgArgs, "--glob", globPattern)
+	}
+	cmd := exec.Command(rgPath, rgArgs...)
+	cmd.Dir = "."
+	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
+}
+
+func SkipHidden(path string) bool {
+	// Check for hidden files (starting with a dot)
+	base := filepath.Base(path)
+	if base != "." && strings.HasPrefix(base, ".") {
+		return true
+	}
+
+	commonIgnoredDirs := map[string]bool{
+		".opencode":        true,
+		"node_modules":     true,
+		"vendor":           true,
+		"dist":             true,
+		"build":            true,
+		"target":           true,
+		".git":             true,
+		".idea":            true,
+		".vscode":          true,
+		"__pycache__":      true,
+		"bin":              true,
+		"obj":              true,
+		"out":              true,
+		"coverage":         true,
+		"tmp":              true,
+		"temp":             true,
+		"logs":             true,
+		"generated":        true,
+		"bower_components": true,
+		"jspm_packages":    true,
+	}
+
+	parts := strings.Split(path, string(os.PathSeparator))
+	for _, part := range parts {
+		if commonIgnoredDirs[part] {
+			return true
+		}
+	}
+	return false
+}
+
+func GlobWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) {
+	fsys := os.DirFS(searchPath)
+	relPattern := strings.TrimPrefix(pattern, "/")
+	var matches []FileInfo
+
+	err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
+		if d.IsDir() {
+			return nil
+		}
+		if SkipHidden(path) {
+			return nil
+		}
+		info, err := d.Info()
+		if err != nil {
+			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
+		}
+
+		matches = append(matches, FileInfo{Path: absPath, ModTime: info.ModTime()})
+		if limit > 0 && len(matches) >= limit*2 {
+			return fs.SkipAll
+		}
+		return nil
+	})
+	if err != nil {
+		return nil, false, fmt.Errorf("glob walk error: %w", err)
+	}
+
+	sort.Slice(matches, func(i, j int) bool {
+		return matches[i].ModTime.After(matches[j].ModTime)
+	})
+
+	truncated := false
+	if limit > 0 && len(matches) > limit {
+		matches = matches[:limit]
+		truncated = true
+	}
+
+	results := make([]string, len(matches))
+	for i, m := range matches {
+		results[i] = m.Path
+	}
+	return results, truncated, nil
+}

internal/llm/tools/glob.go 🔗

@@ -5,16 +5,14 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
-	"io/fs"
-	"os"
 	"os/exec"
 	"path/filepath"
 	"sort"
 	"strings"
-	"time"
 
-	"github.com/bmatcuk/doublestar/v4"
 	"github.com/opencode-ai/opencode/internal/config"
+	"github.com/opencode-ai/opencode/internal/fileutil"
+	"github.com/opencode-ai/opencode/internal/logging"
 )
 
 const (
@@ -55,11 +53,6 @@ TIPS:
 - Always check if results are truncated and refine your search pattern if needed`
 )
 
-type fileInfo struct {
-	path    string
-	modTime time.Time
-}
-
 type GlobParams struct {
 	Pattern string `json:"pattern"`
 	Path    string `json:"path"`
@@ -134,41 +127,20 @@ func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
 }
 
 func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
-	matches, err := globWithRipgrep(pattern, searchPath, limit)
-	if err == nil {
-		return matches, len(matches) >= limit, nil
+	cmdRg := fileutil.GetRgCmd(pattern)
+	if cmdRg != nil {
+		cmdRg.Dir = searchPath
+		matches, err := runRipgrep(cmdRg, searchPath, limit)
+		if err == nil {
+			return matches, len(matches) >= limit && limit > 0, nil
+		}
+		logging.Warn(fmt.Sprintf("Ripgrep execution failed: %v. Falling back to doublestar.", err))
 	}
 
-	return globWithDoublestar(pattern, searchPath, limit)
+	return fileutil.GlobWithDoublestar(pattern, searchPath, limit)
 }
 
-func globWithRipgrep(
-	pattern, searchRoot string,
-	limit int,
-) ([]string, error) {
-	if searchRoot == "" {
-		searchRoot = "."
-	}
-
-	rgBin, err := exec.LookPath("rg")
-	if err != nil {
-		return nil, fmt.Errorf("ripgrep not found in $PATH: %w", err)
-	}
-
-	if !filepath.IsAbs(pattern) && !strings.HasPrefix(pattern, "/") {
-		pattern = "/" + pattern
-	}
-
-	args := []string{
-		"--files",
-		"--null",
-		"--glob", pattern,
-		"-L",
-	}
-
-	cmd := exec.Command(rgBin, args...)
-	cmd.Dir = searchRoot
-
+func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) {
 	out, err := cmd.CombinedOutput()
 	if err != nil {
 		if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
@@ -182,117 +154,22 @@ func globWithRipgrep(
 		if len(p) == 0 {
 			continue
 		}
-		abs := filepath.Join(searchRoot, string(p))
-		if skipHidden(abs) {
+		absPath := string(p)
+		if !filepath.IsAbs(absPath) {
+			absPath = filepath.Join(searchRoot, absPath)
+		}
+		if fileutil.SkipHidden(absPath) {
 			continue
 		}
-		matches = append(matches, abs)
+		matches = append(matches, absPath)
 	}
 
 	sort.SliceStable(matches, func(i, j int) bool {
 		return len(matches[i]) < len(matches[j])
 	})
 
-	if len(matches) > limit {
+	if limit > 0 && len(matches) > limit {
 		matches = matches[:limit]
 	}
 	return matches, nil
 }
-
-func globWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) {
-	fsys := os.DirFS(searchPath)
-
-	relPattern := strings.TrimPrefix(pattern, "/")
-
-	var matches []fileInfo
-
-	err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
-		if d.IsDir() {
-			return nil
-		}
-		if skipHidden(path) {
-			return nil
-		}
-
-		info, err := d.Info()
-		if err != nil {
-			return nil // Skip files we can't access
-		}
-
-		absPath := path // Restore absolute path
-		if !strings.HasPrefix(absPath, searchPath) {
-			absPath = filepath.Join(searchPath, absPath)
-		}
-
-		matches = append(matches, fileInfo{
-			path:    absPath,
-			modTime: info.ModTime(),
-		})
-
-		if len(matches) >= limit*2 { // Collect more than needed for sorting
-			return fs.SkipAll
-		}
-
-		return nil
-	})
-	if err != nil {
-		return nil, false, fmt.Errorf("glob walk error: %w", err)
-	}
-
-	sort.Slice(matches, func(i, j int) bool {
-		return matches[i].modTime.After(matches[j].modTime)
-	})
-
-	truncated := len(matches) > limit
-	if truncated {
-		matches = matches[:limit]
-	}
-
-	results := make([]string, len(matches))
-	for i, m := range matches {
-		results[i] = m.path
-	}
-
-	return results, truncated, nil
-}
-
-func skipHidden(path string) bool {
-	// Check for hidden files (starting with a dot)
-	base := filepath.Base(path)
-	if base != "." && strings.HasPrefix(base, ".") {
-		return true
-	}
-
-	// List of commonly ignored directories in development projects
-	commonIgnoredDirs := map[string]bool{
-		"node_modules":     true,
-		"vendor":           true,
-		"dist":             true,
-		"build":            true,
-		"target":           true,
-		".git":             true,
-		".idea":            true,
-		".vscode":          true,
-		"__pycache__":      true,
-		"bin":              true,
-		"obj":              true,
-		"out":              true,
-		"coverage":         true,
-		"tmp":              true,
-		"temp":             true,
-		"logs":             true,
-		"generated":        true,
-		"bower_components": true,
-		"jspm_packages":    true,
-	}
-
-	// Check if any path component is in our ignore list
-	parts := strings.SplitSeq(path, string(os.PathSeparator))
-	for part := range parts {
-		if commonIgnoredDirs[part] {
-			return true
-		}
-	}
-
-	return false
-}

internal/llm/tools/grep.go 🔗

@@ -15,6 +15,7 @@ import (
 	"time"
 
 	"github.com/opencode-ai/opencode/internal/config"
+	"github.com/opencode-ai/opencode/internal/fileutil"
 )
 
 type GrepParams struct {
@@ -288,7 +289,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
 			return nil // Skip directories
 		}
 
-		if skipHidden(path) {
+		if fileutil.SkipHidden(path) {
 			return nil
 		}
 

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

@@ -5,6 +5,7 @@ import (
 	"os"
 	"os/exec"
 	"slices"
+	"strings"
 	"unicode"
 
 	"github.com/charmbracelet/bubbles/key"
@@ -144,6 +145,11 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, 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:
 		if msg.ID != m.session.ID {
@@ -192,7 +198,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.deleteMode = false
 			return m, nil
 		}
-		// Handle Enter key
+		// Hanlde Enter key
 		if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
 			value := m.textarea.Value()
 			if len(value) > 0 && value[len(value)-1] == '\\' {

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

@@ -4,6 +4,7 @@ import (
 	"github.com/charmbracelet/bubbles/key"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
+	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"
@@ -18,6 +19,33 @@ type Command struct {
 	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
@@ -31,35 +59,20 @@ type CommandDialog interface {
 	tea.Model
 	layout.Bindings
 	SetCommands(commands []Command)
-	SetSelectedCommand(commandID string)
 }
 
 type commandDialogCmp struct {
-	commands          []Command
-	selectedIdx       int
-	width             int
-	height            int
-	selectedCommandID string
+	listView utilComponents.SimpleList[Command]
+	width    int
+	height   int
 }
 
 type commandKeyMap struct {
-	Up     key.Binding
-	Down   key.Binding
 	Enter  key.Binding
 	Escape key.Binding
-	J      key.Binding
-	K      key.Binding
 }
 
 var commandKeys = commandKeyMap{
-	Up: key.NewBinding(
-		key.WithKeys("up"),
-		key.WithHelp("↑", "previous command"),
-	),
-	Down: key.NewBinding(
-		key.WithKeys("down"),
-		key.WithHelp("↓", "next command"),
-	),
 	Enter: key.NewBinding(
 		key.WithKeys("enter"),
 		key.WithHelp("enter", "select command"),
@@ -68,38 +81,22 @@ var commandKeys = commandKeyMap{
 		key.WithKeys("esc"),
 		key.WithHelp("esc", "close"),
 	),
-	J: key.NewBinding(
-		key.WithKeys("j"),
-		key.WithHelp("j", "next command"),
-	),
-	K: key.NewBinding(
-		key.WithKeys("k"),
-		key.WithHelp("k", "previous command"),
-	),
 }
 
 func (c *commandDialogCmp) Init() tea.Cmd {
-	return nil
+	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.KeyMsg:
 		switch {
-		case key.Matches(msg, commandKeys.Up) || key.Matches(msg, commandKeys.K):
-			if c.selectedIdx > 0 {
-				c.selectedIdx--
-			}
-			return c, nil
-		case key.Matches(msg, commandKeys.Down) || key.Matches(msg, commandKeys.J):
-			if c.selectedIdx < len(c.commands)-1 {
-				c.selectedIdx++
-			}
-			return c, nil
 		case key.Matches(msg, commandKeys.Enter):
-			if len(c.commands) > 0 {
+			selectedItem, idx := c.listView.GetSelectedItem()
+			if idx != -1 {
 				return c, util.CmdHandler(CommandSelectedMsg{
-					Command: c.commands[c.selectedIdx],
+					Command: selectedItem,
 				})
 			}
 		case key.Matches(msg, commandKeys.Escape):
@@ -109,78 +106,35 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		c.width = msg.Width
 		c.height = msg.Height
 	}
-	return c, nil
+
+	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() string {
 	t := theme.CurrentTheme()
 	baseStyle := styles.BaseStyle()
-	
-	if len(c.commands) == 0 {
-		return baseStyle.Padding(1, 2).
-			Border(lipgloss.RoundedBorder()).
-			BorderBackground(t.Background()).
-			BorderForeground(t.TextMuted()).
-			Width(40).
-			Render("No commands available")
-	}
 
-	// Calculate max width needed for command titles
-	maxWidth := 40 // Minimum width
-	for _, cmd := range c.commands {
-		if len(cmd.Title) > maxWidth-4 { // Account for padding
-			maxWidth = len(cmd.Title) + 4
-		}
-		if len(cmd.Description) > maxWidth-4 {
-			maxWidth = len(cmd.Description) + 4
-		}
-	}
+	maxWidth := 40
 
-	// Limit height to avoid taking up too much screen space
-	maxVisibleCommands := min(10, len(c.commands))
-
-	// Build the command list
-	commandItems := make([]string, 0, maxVisibleCommands)
-	startIdx := 0
-
-	// If we have more commands than can be displayed, adjust the start index
-	if len(c.commands) > maxVisibleCommands {
-		// Center the selected item when possible
-		halfVisible := maxVisibleCommands / 2
-		if c.selectedIdx >= halfVisible && c.selectedIdx < len(c.commands)-halfVisible {
-			startIdx = c.selectedIdx - halfVisible
-		} else if c.selectedIdx >= len(c.commands)-halfVisible {
-			startIdx = len(c.commands) - maxVisibleCommands
-		}
-	}
+	commands := c.listView.GetItems()
 
-	endIdx := min(startIdx+maxVisibleCommands, len(c.commands))
-
-	for i := startIdx; i < endIdx; i++ {
-		cmd := c.commands[i]
-		itemStyle := baseStyle.Width(maxWidth)
-		descStyle := baseStyle.Width(maxWidth).Foreground(t.TextMuted())
-
-		if i == c.selectedIdx {
-			itemStyle = itemStyle.
-				Background(t.Primary()).
-				Foreground(t.Background()).
-				Bold(true)
-			descStyle = descStyle.
-				Background(t.Primary()).
-				Foreground(t.Background())
+	for _, cmd := range commands {
+		if len(cmd.Title) > maxWidth-4 {
+			maxWidth = len(cmd.Title) + 4
 		}
-
-		title := itemStyle.Padding(0, 1).Render(cmd.Title)
-		description := ""
 		if cmd.Description != "" {
-			description = descStyle.Padding(0, 1).Render(cmd.Description)
-			commandItems = append(commandItems, lipgloss.JoinVertical(lipgloss.Left, title, description))
-		} else {
-			commandItems = append(commandItems, title)
+			if len(cmd.Description) > maxWidth-4 {
+				maxWidth = len(cmd.Description) + 4
+			}
 		}
 	}
 
+	c.listView.SetMaxWidth(maxWidth)
+
 	title := baseStyle.
 		Foreground(t.Primary()).
 		Bold(true).
@@ -192,7 +146,7 @@ func (c *commandDialogCmp) View() string {
 		lipgloss.Left,
 		title,
 		baseStyle.Width(maxWidth).Render(""),
-		baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, commandItems...)),
+		baseStyle.Width(maxWidth).Render(c.listView.View()),
 		baseStyle.Width(maxWidth).Render(""),
 	)
 
@@ -209,41 +163,18 @@ func (c *commandDialogCmp) BindingKeys() []key.Binding {
 }
 
 func (c *commandDialogCmp) SetCommands(commands []Command) {
-	c.commands = commands
-
-	// If we have a selected command ID, find its index
-	if c.selectedCommandID != "" {
-		for i, cmd := range commands {
-			if cmd.ID == c.selectedCommandID {
-				c.selectedIdx = i
-				return
-			}
-		}
-	}
-
-	// Default to first command if selected not found
-	c.selectedIdx = 0
-}
-
-func (c *commandDialogCmp) SetSelectedCommand(commandID string) {
-	c.selectedCommandID = commandID
-
-	// Update the selected index if commands are already loaded
-	if len(c.commands) > 0 {
-		for i, cmd := range c.commands {
-			if cmd.ID == commandID {
-				c.selectedIdx = i
-				return
-			}
-		}
-	}
+	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{
-		commands:          []Command{},
-		selectedIdx:       0,
-		selectedCommandID: "",
+		listView: listView,
 	}
 }

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

@@ -0,0 +1,264 @@
+package dialog
+
+import (
+	"github.com/charmbracelet/bubbles/key"
+	"github.com/charmbracelet/bubbles/textarea"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"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 {
+	tea.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.KeyMsg:
+		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() string {
+	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 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())
+}
+
+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/util/simple-list.go 🔗

@@ -0,0 +1,159 @@
+package utilComponents
+
+import (
+	"github.com/charmbracelet/bubbles/key"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/opencode-ai/opencode/internal/tui/layout"
+	"github.com/opencode-ai/opencode/internal/tui/styles"
+	"github.com/opencode-ai/opencode/internal/tui/theme"
+)
+
+type SimpleListItem interface {
+	Render(selected bool, width int) string
+}
+
+type SimpleList[T SimpleListItem] interface {
+	tea.Model
+	layout.Bindings
+	SetMaxWidth(maxWidth int)
+	GetSelectedItem() (item T, idx int)
+	SetItems(items []T)
+	GetItems() []T
+}
+
+type simpleListCmp[T SimpleListItem] struct {
+	fallbackMsg         string
+	items               []T
+	selectedIdx         int
+	maxWidth            int
+	maxVisibleItems     int
+	useAlphaNumericKeys bool
+	width               int
+	height              int
+}
+
+type simpleListKeyMap struct {
+	Up        key.Binding
+	Down      key.Binding
+	UpAlpha   key.Binding
+	DownAlpha key.Binding
+}
+
+var simpleListKeys = simpleListKeyMap{
+	Up: key.NewBinding(
+		key.WithKeys("up"),
+		key.WithHelp("↑", "previous list item"),
+	),
+	Down: key.NewBinding(
+		key.WithKeys("down"),
+		key.WithHelp("↓", "next list item"),
+	),
+	UpAlpha: key.NewBinding(
+		key.WithKeys("k"),
+		key.WithHelp("k", "previous list item"),
+	),
+	DownAlpha: key.NewBinding(
+		key.WithKeys("j"),
+		key.WithHelp("j", "next list item"),
+	),
+}
+
+func (c *simpleListCmp[T]) Init() tea.Cmd {
+	return nil
+}
+
+func (c *simpleListCmp[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch {
+		case key.Matches(msg, simpleListKeys.Up) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.UpAlpha)):
+			if c.selectedIdx > 0 {
+				c.selectedIdx--
+			}
+			return c, nil
+		case key.Matches(msg, simpleListKeys.Down) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.DownAlpha)):
+			if c.selectedIdx < len(c.items)-1 {
+				c.selectedIdx++
+			}
+			return c, nil
+		}
+	}
+
+	return c, nil
+}
+
+func (c *simpleListCmp[T]) BindingKeys() []key.Binding {
+	return layout.KeyMapToSlice(simpleListKeys)
+}
+
+func (c *simpleListCmp[T]) GetSelectedItem() (T, int) {
+	if len(c.items) > 0 {
+		return c.items[c.selectedIdx], c.selectedIdx
+	}
+
+	var zero T
+	return zero, -1
+}
+
+func (c *simpleListCmp[T]) SetItems(items []T) {
+	c.selectedIdx = 0
+	c.items = items
+}
+
+func (c *simpleListCmp[T]) GetItems() []T {
+	return c.items
+}
+
+func (c *simpleListCmp[T]) SetMaxWidth(width int) {
+	c.maxWidth = width
+}
+
+func (c *simpleListCmp[T]) View() string {
+	t := theme.CurrentTheme()
+	baseStyle := styles.BaseStyle()
+
+	items := c.items
+	maxWidth := c.maxWidth
+	maxVisibleItems := min(c.maxVisibleItems, len(items))
+	startIdx := 0
+
+	if len(items) <= 0 {
+		return baseStyle.
+			Background(t.Background()).
+			Padding(0, 1).
+			Width(maxWidth).
+			Render(c.fallbackMsg)
+	}
+
+	if len(items) > maxVisibleItems {
+		halfVisible := maxVisibleItems / 2
+		if c.selectedIdx >= halfVisible && c.selectedIdx < len(items)-halfVisible {
+			startIdx = c.selectedIdx - halfVisible
+		} else if c.selectedIdx >= len(items)-halfVisible {
+			startIdx = len(items) - maxVisibleItems
+		}
+	}
+
+	endIdx := min(startIdx+maxVisibleItems, len(items))
+
+	listItems := make([]string, 0, maxVisibleItems)
+
+	for i := startIdx; i < endIdx; i++ {
+		item := items[i]
+		title := item.Render(i == c.selectedIdx, maxWidth)
+		listItems = append(listItems, title)
+	}
+
+	return lipgloss.JoinVertical(lipgloss.Left, listItems...)
+}
+
+func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) SimpleList[T] {
+	return &simpleListCmp[T]{
+		fallbackMsg:         fallbackMsg,
+		items:               items,
+		maxVisibleItems:     maxVisibleItems,
+		useAlphaNumericKeys: useAlphaNumericKeys,
+		selectedIdx:         0,
+	}
+}

internal/tui/page/chat.go 🔗

@@ -6,7 +6,9 @@ import (
 
 	"github.com/charmbracelet/bubbles/key"
 	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
 	"github.com/opencode-ai/opencode/internal/app"
+	"github.com/opencode-ai/opencode/internal/completions"
 	"github.com/opencode-ai/opencode/internal/message"
 	"github.com/opencode-ai/opencode/internal/session"
 	"github.com/opencode-ai/opencode/internal/tui/components/chat"
@@ -18,19 +20,26 @@ import (
 var ChatPage PageID = "chat"
 
 type chatPage struct {
-	app      *app.App
-	editor   layout.Container
-	messages layout.Container
-	layout   layout.SplitPaneLayout
-	session  session.Session
+	app                  *app.App
+	editor               layout.Container
+	messages             layout.Container
+	layout               layout.SplitPaneLayout
+	session              session.Session
+	completionDialog     dialog.CompletionDialog
+	showCompletionDialog bool
 }
 
 type ChatKeyMap struct {
-	NewSession key.Binding
-	Cancel     key.Binding
+	ShowCompletionDialog 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"),
@@ -44,6 +53,7 @@ var keyMap = ChatKeyMap{
 func (p *chatPage) Init() tea.Cmd {
 	cmds := []tea.Cmd{
 		p.layout.Init(),
+		p.completionDialog.Init(),
 	}
 	return tea.Batch(cmds...)
 }
@@ -54,6 +64,8 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.WindowSizeMsg:
 		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 {
@@ -90,6 +102,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		p.session = msg
 	case tea.KeyMsg:
 		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(
@@ -105,9 +120,23 @@ 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.KeyMsg); 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)
+
 	return p, tea.Batch(cmds...)
 }
 
@@ -155,7 +184,25 @@ func (p *chatPage) GetSize() (int, int) {
 }
 
 func (p *chatPage) View() string {
-	return p.layout.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()
+
+		layoutView = layout.PlaceOverlay(
+			0,
+			layoutHeight-editorHeight-lipgloss.Height(overlay),
+			overlay,
+			layoutView,
+			false,
+		)
+	}
+
+	return layoutView
 }
 
 func (p *chatPage) BindingKeys() []key.Binding {
@@ -166,6 +213,9 @@ func (p *chatPage) BindingKeys() []key.Binding {
 }
 
 func NewChatPage(app *app.App) tea.Model {
+	cg := completions.NewFileAndFolderContextGroup()
+	completionDialog := dialog.NewCompletionDialogCmp(cg)
+
 	messagesContainer := layout.NewContainer(
 		chat.NewMessagesCmp(app),
 		layout.WithPadding(1, 1, 0, 1),
@@ -175,9 +225,10 @@ func NewChatPage(app *app.App) tea.Model {
 		layout.WithBorder(true, false, false, false),
 	)
 	return &chatPage{
-		app:      app,
-		editor:   editorContainer,
-		messages: messagesContainer,
+		app:              app,
+		editor:           editorContainer,
+		messages:         messagesContainer,
+		completionDialog: completionDialog,
 		layout: layout.NewSplitPane(
 			layout.WithLeftPanel(messagesContainer),
 			layout.WithBottomPanel(editorContainer),