on main help sign in
Add completion menu for file paths (#145)

* 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

commit49c710b900fd2073108996cf89cfbdb057001f85 authorAdictya <aditya.26101@gmail.com> dateThu 15 May 2025, 19:37 (1 year ago) parent3e42475
11 files changed+933−287
go.mod+3−2 go.sum+2 internal/completions/files-folders.go+191 internal/fileutil/fileutil.go+163 internal/llm/tools/glob.go+19−142 internal/llm/tools/grep.go+2−1 internal/tui/components/chat/editor.go+7−1 internal/tui/components/dialog/commands.go+61−130 internal/tui/components/dialog/complete.go+264 internal/tui/components/util/simple-list.go+159 internal/tui/page/chat.go+62−11
go.mod+3−2
@@ -35,8 +35,6 @@ require (
3535
3636require (
3737 cloud.google.com/go v0.116.0 // indirect
38− github.com/google/go-cmp v0.7.0 // indirect
39− github.com/gorilla/websocket v1.5.3 // indirect
4038 cloud.google.com/go/auth v0.13.0 // indirect
4139 cloud.google.com/go/compute/metadata v0.6.0 // indirect
4240 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
@@ -72,12 +70,15 @@ require (
7270 github.com/go-logr/stdr v1.2.2 // indirect
7371 github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
7472 github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
73+ github.com/google/go-cmp v0.7.0 // indirect
7574 github.com/google/s2a-go v0.1.8 // indirect
7675 github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
7776 github.com/googleapis/gax-go/v2 v2.14.1 // indirect
7877 github.com/gorilla/css v1.0.1 // indirect
78+ github.com/gorilla/websocket v1.5.3 // indirect
7979 github.com/inconshreveable/mousetrap v1.1.0 // indirect
8080 github.com/kylelemons/godebug v1.1.0 // indirect
81+ github.com/lithammer/fuzzysearch v1.1.8
8182 github.com/lucasb-eyer/go-colorful v1.2.0
8283 github.com/mattn/go-isatty v0.0.20 // indirect
8384 github.com/mattn/go-localereader v0.0.1 // indirect
go.sum+2
@@ -144,6 +144,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
144144github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
145145github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
146146github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
147+github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
148+github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
147149github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms=
148150github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231/go.mod h1:S5etECMx+sZnW0Gm100Ma9J1PgVCTgNyFaqGu2b08b4=
149151github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
internal/completions/files-folders.gonew file+191
@@ -0,0 +1,191 @@
1+package completions
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "os/exec"
7+ "path/filepath"
8+
9+ "github.com/lithammer/fuzzysearch/fuzzy"
10+ "github.com/opencode-ai/opencode/internal/fileutil"
11+ "github.com/opencode-ai/opencode/internal/logging"
12+ "github.com/opencode-ai/opencode/internal/tui/components/dialog"
13+)
14+
15+type filesAndFoldersContextGroup struct {
16+ prefix string
17+}
18+
19+func (cg *filesAndFoldersContextGroup) GetId() string {
20+ return cg.prefix
21+}
22+
23+func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI {
24+ return dialog.NewCompletionItem(dialog.CompletionItem{
25+ Title: "Files & Folders",
26+ Value: "files",
27+ })
28+}
29+
30+func processNullTerminatedOutput(outputBytes []byte) []string {
31+ if len(outputBytes) > 0 && outputBytes[len(outputBytes)-1] == 0 {
32+ outputBytes = outputBytes[:len(outputBytes)-1]
33+ }
34+
35+ if len(outputBytes) == 0 {
36+ return []string{}
37+ }
38+
39+ split := bytes.Split(outputBytes, []byte{0})
40+ matches := make([]string, 0, len(split))
41+
42+ for _, p := range split {
43+ if len(p) == 0 {
44+ continue
45+ }
46+
47+ path := string(p)
48+ path = filepath.Join(".", path)
49+
50+ if !fileutil.SkipHidden(path) {
51+ matches = append(matches, path)
52+ }
53+ }
54+
55+ return matches
56+}
57+
58+func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) {
59+ cmdRg := fileutil.GetRgCmd("") // No glob pattern for this use case
60+ cmdFzf := fileutil.GetFzfCmd(query)
61+
62+ var matches []string
63+ // Case 1: Both rg and fzf available
64+ if cmdRg != nil && cmdFzf != nil {
65+ rgPipe, err := cmdRg.StdoutPipe()
66+ if err != nil {
67+ return nil, fmt.Errorf("failed to get rg stdout pipe: %w", err)
68+ }
69+ defer rgPipe.Close()
70+
71+ cmdFzf.Stdin = rgPipe
72+ var fzfOut bytes.Buffer
73+ var fzfErr bytes.Buffer
74+ cmdFzf.Stdout = &fzfOut
75+ cmdFzf.Stderr = &fzfErr
76+
77+ if err := cmdFzf.Start(); err != nil {
78+ return nil, fmt.Errorf("failed to start fzf: %w", err)
79+ }
80+
81+ errRg := cmdRg.Run()
82+ errFzf := cmdFzf.Wait()
83+
84+ if errRg != nil {
85+ logging.Warn(fmt.Sprintf("rg command failed during pipe: %v", errRg))
86+ }
87+
88+ if errFzf != nil {
89+ if exitErr, ok := errFzf.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
90+ return []string{}, nil // No matches from fzf
91+ }
92+ return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", errFzf, fzfErr.String())
93+ }
94+
95+ matches = processNullTerminatedOutput(fzfOut.Bytes())
96+
97+ // Case 2: Only rg available
98+ } else if cmdRg != nil {
99+ logging.Debug("Using Ripgrep with fuzzy match fallback for file completions")
100+ var rgOut bytes.Buffer
101+ var rgErr bytes.Buffer
102+ cmdRg.Stdout = &rgOut
103+ cmdRg.Stderr = &rgErr
104+
105+ if err := cmdRg.Run(); err != nil {
106+ return nil, fmt.Errorf("rg command failed: %w\nStderr: %s", err, rgErr.String())
107+ }
108+
109+ allFiles := processNullTerminatedOutput(rgOut.Bytes())
110+ matches = fuzzy.Find(query, allFiles)
111+
112+ // Case 3: Only fzf available
113+ } else if cmdFzf != nil {
114+ logging.Debug("Using FZF with doublestar fallback for file completions")
115+ files, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0)
116+ if err != nil {
117+ return nil, fmt.Errorf("failed to list files for fzf: %w", err)
118+ }
119+
120+ allFiles := make([]string, 0, len(files))
121+ for _, file := range files {
122+ if !fileutil.SkipHidden(file) {
123+ allFiles = append(allFiles, file)
124+ }
125+ }
126+
127+ var fzfIn bytes.Buffer
128+ for _, file := range allFiles {
129+ fzfIn.WriteString(file)
130+ fzfIn.WriteByte(0)
131+ }
132+
133+ cmdFzf.Stdin = &fzfIn
134+ var fzfOut bytes.Buffer
135+ var fzfErr bytes.Buffer
136+ cmdFzf.Stdout = &fzfOut
137+ cmdFzf.Stderr = &fzfErr
138+
139+ if err := cmdFzf.Run(); err != nil {
140+ if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
141+ return []string{}, nil
142+ }
143+ return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", err, fzfErr.String())
144+ }
145+
146+ matches = processNullTerminatedOutput(fzfOut.Bytes())
147+
148+ // Case 4: Fallback to doublestar with fuzzy match
149+ } else {
150+ logging.Debug("Using doublestar with fuzzy match for file completions")
151+ allFiles, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0)
152+ if err != nil {
153+ return nil, fmt.Errorf("failed to glob files: %w", err)
154+ }
155+
156+ filteredFiles := make([]string, 0, len(allFiles))
157+ for _, file := range allFiles {
158+ if !fileutil.SkipHidden(file) {
159+ filteredFiles = append(filteredFiles, file)
160+ }
161+ }
162+
163+ matches = fuzzy.Find(query, filteredFiles)
164+ }
165+
166+ return matches, nil
167+}
168+
169+func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
170+ matches, err := cg.getFiles(query)
171+ if err != nil {
172+ return nil, err
173+ }
174+
175+ items := make([]dialog.CompletionItemI, 0, len(matches))
176+ for _, file := range matches {
177+ item := dialog.NewCompletionItem(dialog.CompletionItem{
178+ Title: file,
179+ Value: file,
180+ })
181+ items = append(items, item)
182+ }
183+
184+ return items, nil
185+}
186+
187+func NewFileAndFolderContextGroup() dialog.CompletionProvider {
188+ return &filesAndFoldersContextGroup{
189+ prefix: "file",
190+ }
191+}
internal/fileutil/fileutil.gonew file+163
@@ -0,0 +1,163 @@
1+package fileutil
2+
3+import (
4+ "fmt"
5+ "io/fs"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "sort"
10+ "strings"
11+ "time"
12+
13+ "github.com/bmatcuk/doublestar/v4"
14+ "github.com/opencode-ai/opencode/internal/logging"
15+)
16+
17+var (
18+ rgPath string
19+ fzfPath string
20+)
21+
22+func init() {
23+ var err error
24+ rgPath, err = exec.LookPath("rg")
25+ if err != nil {
26+ logging.Warn("Ripgrep (rg) not found in $PATH. Some features might be limited or slower.")
27+ rgPath = ""
28+ }
29+ fzfPath, err = exec.LookPath("fzf")
30+ if err != nil {
31+ logging.Warn("FZF not found in $PATH. Some features might be limited or slower.")
32+ fzfPath = ""
33+ }
34+}
35+
36+func GetRgCmd(globPattern string) *exec.Cmd {
37+ if rgPath == "" {
38+ return nil
39+ }
40+ rgArgs := []string{
41+ "--files",
42+ "-L",
43+ "--null",
44+ }
45+ if globPattern != "" {
46+ if !filepath.IsAbs(globPattern) && !strings.HasPrefix(globPattern, "/") {
47+ globPattern = "/" + globPattern
48+ }
49+ rgArgs = append(rgArgs, "--glob", globPattern)
50+ }
51+ cmd := exec.Command(rgPath, rgArgs...)
52+ cmd.Dir = "."
53+ return cmd
54+}
55+
56+func GetFzfCmd(query string) *exec.Cmd {
57+ if fzfPath == "" {
58+ return nil
59+ }
60+ fzfArgs := []string{
61+ "--filter",
62+ query,
63+ "--read0",
64+ "--print0",
65+ }
66+ cmd := exec.Command(fzfPath, fzfArgs...)
67+ cmd.Dir = "."
68+ return cmd
69+}
70+
71+type FileInfo struct {
72+ Path string
73+ ModTime time.Time
74+}
75+
76+func SkipHidden(path string) bool {
77+ // Check for hidden files (starting with a dot)
78+ base := filepath.Base(path)
79+ if base != "." && strings.HasPrefix(base, ".") {
80+ return true
81+ }
82+
83+ commonIgnoredDirs := map[string]bool{
84+ ".opencode": true,
85+ "node_modules": true,
86+ "vendor": true,
87+ "dist": true,
88+ "build": true,
89+ "target": true,
90+ ".git": true,
91+ ".idea": true,
92+ ".vscode": true,
93+ "__pycache__": true,
94+ "bin": true,
95+ "obj": true,
96+ "out": true,
97+ "coverage": true,
98+ "tmp": true,
99+ "temp": true,
100+ "logs": true,
101+ "generated": true,
102+ "bower_components": true,
103+ "jspm_packages": true,
104+ }
105+
106+ parts := strings.Split(path, string(os.PathSeparator))
107+ for _, part := range parts {
108+ if commonIgnoredDirs[part] {
109+ return true
110+ }
111+ }
112+ return false
113+}
114+
115+func GlobWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) {
116+ fsys := os.DirFS(searchPath)
117+ relPattern := strings.TrimPrefix(pattern, "/")
118+ var matches []FileInfo
119+
120+ err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
121+ if d.IsDir() {
122+ return nil
123+ }
124+ if SkipHidden(path) {
125+ return nil
126+ }
127+ info, err := d.Info()
128+ if err != nil {
129+ return nil
130+ }
131+ absPath := path
132+ if !strings.HasPrefix(absPath, searchPath) && searchPath != "." {
133+ absPath = filepath.Join(searchPath, absPath)
134+ } else if !strings.HasPrefix(absPath, "/") && searchPath == "." {
135+ absPath = filepath.Join(searchPath, absPath) // Ensure relative paths are joined correctly
136+ }
137+
138+ matches = append(matches, FileInfo{Path: absPath, ModTime: info.ModTime()})
139+ if limit > 0 && len(matches) >= limit*2 {
140+ return fs.SkipAll
141+ }
142+ return nil
143+ })
144+ if err != nil {
145+ return nil, false, fmt.Errorf("glob walk error: %w", err)
146+ }
147+
148+ sort.Slice(matches, func(i, j int) bool {
149+ return matches[i].ModTime.After(matches[j].ModTime)
150+ })
151+
152+ truncated := false
153+ if limit > 0 && len(matches) > limit {
154+ matches = matches[:limit]
155+ truncated = true
156+ }
157+
158+ results := make([]string, len(matches))
159+ for i, m := range matches {
160+ results[i] = m.Path
161+ }
162+ return results, truncated, nil
163+}
internal/llm/tools/glob.go+19−142
@@ -5,16 +5,14 @@ import (
55 "context"
66 "encoding/json"
77 "fmt"
8− "io/fs"
9− "os"
108 "os/exec"
119 "path/filepath"
1210 "sort"
1311 "strings"
14− "time"
1512
16− "github.com/bmatcuk/doublestar/v4"
1713 "github.com/opencode-ai/opencode/internal/config"
14+ "github.com/opencode-ai/opencode/internal/fileutil"
15+ "github.com/opencode-ai/opencode/internal/logging"
1816)
1917
2018const (
@@ -55,11 +53,6 @@ TIPS:
5553- Always check if results are truncated and refine your search pattern if needed`
5654)
5755
58−type fileInfo struct {
59− path string
60− modTime time.Time
61−}
62−
6356type GlobParams struct {
6457 Pattern string `json:"pattern"`
6558 Path string `json:"path"`
@@ -134,41 +127,20 @@ func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
134127}
135128
136129func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
137− matches, err := globWithRipgrep(pattern, searchPath, limit)
138− if err == nil {
139− return matches, len(matches) >= limit, nil
130+ cmdRg := fileutil.GetRgCmd(pattern)
131+ if cmdRg != nil {
132+ cmdRg.Dir = searchPath
133+ matches, err := runRipgrep(cmdRg, searchPath, limit)
134+ if err == nil {
135+ return matches, len(matches) >= limit && limit > 0, nil
136+ }
137+ logging.Warn(fmt.Sprintf("Ripgrep execution failed: %v. Falling back to doublestar.", err))
140138 }
141139
142− return globWithDoublestar(pattern, searchPath, limit)
140+ return fileutil.GlobWithDoublestar(pattern, searchPath, limit)
143141}
144142
145−func globWithRipgrep(
146− pattern, searchRoot string,
147− limit int,
148−) ([]string, error) {
149− if searchRoot == "" {
150− searchRoot = "."
151− }
152−
153− rgBin, err := exec.LookPath("rg")
154− if err != nil {
155− return nil, fmt.Errorf("ripgrep not found in $PATH: %w", err)
156− }
157−
158− if !filepath.IsAbs(pattern) && !strings.HasPrefix(pattern, "/") {
159− pattern = "/" + pattern
160− }
161−
162− args := []string{
163− "--files",
164− "--null",
165− "--glob", pattern,
166− "-L",
167− }
168−
169− cmd := exec.Command(rgBin, args...)
170− cmd.Dir = searchRoot
171−
143+func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) {
172144 out, err := cmd.CombinedOutput()
173145 if err != nil {
174146 if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
@@ -182,117 +154,22 @@ func globWithRipgrep(
182154 if len(p) == 0 {
183155 continue
184156 }
185− abs := filepath.Join(searchRoot, string(p))
186− if skipHidden(abs) {
157+ absPath := string(p)
158+ if !filepath.IsAbs(absPath) {
159+ absPath = filepath.Join(searchRoot, absPath)
160+ }
161+ if fileutil.SkipHidden(absPath) {
187162 continue
188163 }
189− matches = append(matches, abs)
164+ matches = append(matches, absPath)
190165 }
191166
192167 sort.SliceStable(matches, func(i, j int) bool {
193168 return len(matches[i]) < len(matches[j])
194169 })
195170
196− if len(matches) > limit {
171+ if limit > 0 && len(matches) > limit {
197172 matches = matches[:limit]
198173 }
199174 return matches, nil
200175}
201−
202−func globWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) {
203− fsys := os.DirFS(searchPath)
204−
205− relPattern := strings.TrimPrefix(pattern, "/")
206−
207− var matches []fileInfo
208−
209− err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
210− if d.IsDir() {
211− return nil
212− }
213− if skipHidden(path) {
214− return nil
215− }
216−
217− info, err := d.Info()
218− if err != nil {
219− return nil // Skip files we can't access
220− }
221−
222− absPath := path // Restore absolute path
223− if !strings.HasPrefix(absPath, searchPath) {
224− absPath = filepath.Join(searchPath, absPath)
225− }
226−
227− matches = append(matches, fileInfo{
228− path: absPath,
229− modTime: info.ModTime(),
230− })
231−
232− if len(matches) >= limit*2 { // Collect more than needed for sorting
233− return fs.SkipAll
234− }
235−
236− return nil
237− })
238− if err != nil {
239− return nil, false, fmt.Errorf("glob walk error: %w", err)
240− }
241−
242− sort.Slice(matches, func(i, j int) bool {
243− return matches[i].modTime.After(matches[j].modTime)
244− })
245−
246− truncated := len(matches) > limit
247− if truncated {
248− matches = matches[:limit]
249− }
250−
251− results := make([]string, len(matches))
252− for i, m := range matches {
253− results[i] = m.path
254− }
255−
256− return results, truncated, nil
257−}
258−
259−func skipHidden(path string) bool {
260− // Check for hidden files (starting with a dot)
261− base := filepath.Base(path)
262− if base != "." && strings.HasPrefix(base, ".") {
263− return true
264− }
265−
266− // List of commonly ignored directories in development projects
267− commonIgnoredDirs := map[string]bool{
268− "node_modules": true,
269− "vendor": true,
270− "dist": true,
271− "build": true,
272− "target": true,
273− ".git": true,
274− ".idea": true,
275− ".vscode": true,
276− "__pycache__": true,
277− "bin": true,
278− "obj": true,
279− "out": true,
280− "coverage": true,
281− "tmp": true,
282− "temp": true,
283− "logs": true,
284− "generated": true,
285− "bower_components": true,
286− "jspm_packages": true,
287− }
288−
289− // Check if any path component is in our ignore list
290− parts := strings.SplitSeq(path, string(os.PathSeparator))
291− for part := range parts {
292− if commonIgnoredDirs[part] {
293− return true
294− }
295− }
296−
297− return false
298−}
internal/llm/tools/grep.go+2−1
@@ -15,6 +15,7 @@ import (
1515 "time"
1616
1717 "github.com/opencode-ai/opencode/internal/config"
18+ "github.com/opencode-ai/opencode/internal/fileutil"
1819)
1920
2021type GrepParams struct {
@@ -288,7 +289,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
288289 return nil // Skip directories
289290 }
290291
291− if skipHidden(path) {
292+ if fileutil.SkipHidden(path) {
292293 return nil
293294 }
294295
internal/tui/components/chat/editor.go+7−1
@@ -5,6 +5,7 @@ import (
55 "os"
66 "os/exec"
77 "slices"
8+ "strings"
89 "unicode"
910
1011 "github.com/charmbracelet/bubbles/key"
@@ -144,6 +145,11 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
144145 switch msg := msg.(type) {
145146 case dialog.ThemeChangedMsg:
146147 m.textarea = CreateTextArea(&m.textarea)
148+ case dialog.CompletionSelectedMsg:
149+ existingValue := m.textarea.Value()
150+ modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
151+
152+ m.textarea.SetValue(modifiedValue)
147153 return m, nil
148154 case SessionSelectedMsg:
149155 if msg.ID != m.session.ID {
@@ -192,7 +198,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
192198 m.deleteMode = false
193199 return m, nil
194200 }
195− // Handle Enter key
201+ // Hanlde Enter key
196202 if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
197203 value := m.textarea.Value()
198204 if len(value) > 0 && value[len(value)-1] == '\\' {
internal/tui/components/dialog/commands.go+61−130
@@ -4,6 +4,7 @@ import (
44 "github.com/charmbracelet/bubbles/key"
55 tea "github.com/charmbracelet/bubbletea"
66 "github.com/charmbracelet/lipgloss"
7+ utilComponents "github.com/opencode-ai/opencode/internal/tui/components/util"
78 "github.com/opencode-ai/opencode/internal/tui/layout"
89 "github.com/opencode-ai/opencode/internal/tui/styles"
910 "github.com/opencode-ai/opencode/internal/tui/theme"
@@ -18,6 +19,33 @@ type Command struct {
1819 Handler func(cmd Command) tea.Cmd
1920}
2021
22+func (ci Command) Render(selected bool, width int) string {
23+ t := theme.CurrentTheme()
24+ baseStyle := styles.BaseStyle()
25+
26+ descStyle := baseStyle.Width(width).Foreground(t.TextMuted())
27+ itemStyle := baseStyle.Width(width).
28+ Foreground(t.Text()).
29+ Background(t.Background())
30+
31+ if selected {
32+ itemStyle = itemStyle.
33+ Background(t.Primary()).
34+ Foreground(t.Background()).
35+ Bold(true)
36+ descStyle = descStyle.
37+ Background(t.Primary()).
38+ Foreground(t.Background())
39+ }
40+
41+ title := itemStyle.Padding(0, 1).Render(ci.Title)
42+ if ci.Description != "" {
43+ description := descStyle.Padding(0, 1).Render(ci.Description)
44+ return lipgloss.JoinVertical(lipgloss.Left, title, description)
45+ }
46+ return title
47+}
48+
2149// CommandSelectedMsg is sent when a command is selected
2250type CommandSelectedMsg struct {
2351 Command Command
@@ -31,35 +59,20 @@ type CommandDialog interface {
3159 tea.Model
3260 layout.Bindings
3361 SetCommands(commands []Command)
34− SetSelectedCommand(commandID string)
3562}
3663
3764type commandDialogCmp struct {
38− commands []Command
39− selectedIdx int
40− width int
41− height int
42− selectedCommandID string
65+ listView utilComponents.SimpleList[Command]
66+ width int
67+ height int
4368}
4469
4570type commandKeyMap struct {
46− Up key.Binding
47− Down key.Binding
4871 Enter key.Binding
4972 Escape key.Binding
50− J key.Binding
51− K key.Binding
5273}
5374
5475var commandKeys = commandKeyMap{
55− Up: key.NewBinding(
56− key.WithKeys("up"),
57− key.WithHelp("↑", "previous command"),
58− ),
59− Down: key.NewBinding(
60− key.WithKeys("down"),
61− key.WithHelp("↓", "next command"),
62− ),
6376 Enter: key.NewBinding(
6477 key.WithKeys("enter"),
6578 key.WithHelp("enter", "select command"),
@@ -68,38 +81,22 @@ var commandKeys = commandKeyMap{
6881 key.WithKeys("esc"),
6982 key.WithHelp("esc", "close"),
7083 ),
71− J: key.NewBinding(
72− key.WithKeys("j"),
73− key.WithHelp("j", "next command"),
74− ),
75− K: key.NewBinding(
76− key.WithKeys("k"),
77− key.WithHelp("k", "previous command"),
78− ),
7984}
8085
8186func (c *commandDialogCmp) Init() tea.Cmd {
82− return nil
87+ return c.listView.Init()
8388}
8489
8590func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
91+ var cmds []tea.Cmd
8692 switch msg := msg.(type) {
8793 case tea.KeyMsg:
8894 switch {
89− case key.Matches(msg, commandKeys.Up) || key.Matches(msg, commandKeys.K):
90− if c.selectedIdx > 0 {
91− c.selectedIdx--
92− }
93− return c, nil
94− case key.Matches(msg, commandKeys.Down) || key.Matches(msg, commandKeys.J):
95− if c.selectedIdx < len(c.commands)-1 {
96− c.selectedIdx++
97− }
98− return c, nil
9995 case key.Matches(msg, commandKeys.Enter):
100− if len(c.commands) > 0 {
96+ selectedItem, idx := c.listView.GetSelectedItem()
97+ if idx != -1 {
10198 return c, util.CmdHandler(CommandSelectedMsg{
102− Command: c.commands[c.selectedIdx],
99+ Command: selectedItem,
103100 })
104101 }
105102 case key.Matches(msg, commandKeys.Escape):
@@ -109,78 +106,35 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
109106 c.width = msg.Width
110107 c.height = msg.Height
111108 }
112− return c, nil
109+
110+ u, cmd := c.listView.Update(msg)
111+ c.listView = u.(utilComponents.SimpleList[Command])
112+ cmds = append(cmds, cmd)
113+
114+ return c, tea.Batch(cmds...)
113115}
114116
115117func (c *commandDialogCmp) View() string {
116118 t := theme.CurrentTheme()
117119 baseStyle := styles.BaseStyle()
118−
119− if len(c.commands) == 0 {
120− return baseStyle.Padding(1, 2).
121− Border(lipgloss.RoundedBorder()).
122− BorderBackground(t.Background()).
123− BorderForeground(t.TextMuted()).
124− Width(40).
125− Render("No commands available")
126− }
127120
128− // Calculate max width needed for command titles
129− maxWidth := 40 // Minimum width
130− for _, cmd := range c.commands {
131− if len(cmd.Title) > maxWidth-4 { // Account for padding
132− maxWidth = len(cmd.Title) + 4
133− }
134− if len(cmd.Description) > maxWidth-4 {
135− maxWidth = len(cmd.Description) + 4
136− }
137− }
121+ maxWidth := 40
138122
139− // Limit height to avoid taking up too much screen space
140− maxVisibleCommands := min(10, len(c.commands))
141−
142− // Build the command list
143− commandItems := make([]string, 0, maxVisibleCommands)
144− startIdx := 0
145−
146− // If we have more commands than can be displayed, adjust the start index
147− if len(c.commands) > maxVisibleCommands {
148− // Center the selected item when possible
149− halfVisible := maxVisibleCommands / 2
150− if c.selectedIdx >= halfVisible && c.selectedIdx < len(c.commands)-halfVisible {
151− startIdx = c.selectedIdx - halfVisible
152− } else if c.selectedIdx >= len(c.commands)-halfVisible {
153− startIdx = len(c.commands) - maxVisibleCommands
154− }
155− }
123+ commands := c.listView.GetItems()
156124
157− endIdx := min(startIdx+maxVisibleCommands, len(c.commands))
158−
159− for i := startIdx; i < endIdx; i++ {
160− cmd := c.commands[i]
161− itemStyle := baseStyle.Width(maxWidth)
162− descStyle := baseStyle.Width(maxWidth).Foreground(t.TextMuted())
163−
164− if i == c.selectedIdx {
165− itemStyle = itemStyle.
166− Background(t.Primary()).
167− Foreground(t.Background()).
168− Bold(true)
169− descStyle = descStyle.
170− Background(t.Primary()).
171− Foreground(t.Background())
125+ for _, cmd := range commands {
126+ if len(cmd.Title) > maxWidth-4 {
127+ maxWidth = len(cmd.Title) + 4
172128 }
173−
174− title := itemStyle.Padding(0, 1).Render(cmd.Title)
175− description := ""
176129 if cmd.Description != "" {
177− description = descStyle.Padding(0, 1).Render(cmd.Description)
178− commandItems = append(commandItems, lipgloss.JoinVertical(lipgloss.Left, title, description))
179− } else {
180− commandItems = append(commandItems, title)
130+ if len(cmd.Description) > maxWidth-4 {
131+ maxWidth = len(cmd.Description) + 4
132+ }
181133 }
182134 }
183135
136+ c.listView.SetMaxWidth(maxWidth)
137+
184138 title := baseStyle.
185139 Foreground(t.Primary()).
186140 Bold(true).
@@ -192,7 +146,7 @@ func (c *commandDialogCmp) View() string {
192146 lipgloss.Left,
193147 title,
194148 baseStyle.Width(maxWidth).Render(""),
195− baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, commandItems...)),
149+ baseStyle.Width(maxWidth).Render(c.listView.View()),
196150 baseStyle.Width(maxWidth).Render(""),
197151 )
198152
@@ -209,41 +163,18 @@ func (c *commandDialogCmp) BindingKeys() []key.Binding {
209163}
210164
211165func (c *commandDialogCmp) SetCommands(commands []Command) {
212− c.commands = commands
213−
214− // If we have a selected command ID, find its index
215− if c.selectedCommandID != "" {
216− for i, cmd := range commands {
217− if cmd.ID == c.selectedCommandID {
218− c.selectedIdx = i
219− return
220− }
221− }
222− }
223−
224− // Default to first command if selected not found
225− c.selectedIdx = 0
226−}
227−
228−func (c *commandDialogCmp) SetSelectedCommand(commandID string) {
229− c.selectedCommandID = commandID
230−
231− // Update the selected index if commands are already loaded
232− if len(c.commands) > 0 {
233− for i, cmd := range c.commands {
234− if cmd.ID == commandID {
235− c.selectedIdx = i
236− return
237− }
238− }
239− }
166+ c.listView.SetItems(commands)
240167}
241168
242169// NewCommandDialogCmp creates a new command selection dialog
243170func NewCommandDialogCmp() CommandDialog {
171+ listView := utilComponents.NewSimpleList[Command](
172+ []Command{},
173+ 10,
174+ "No commands available",
175+ true,
176+ )
244177 return &commandDialogCmp{
245− commands: []Command{},
246− selectedIdx: 0,
247− selectedCommandID: "",
178+ listView: listView,
248179 }
249180}
internal/tui/components/dialog/complete.gonew file+264
@@ -0,0 +1,264 @@
1+package dialog
2+
3+import (
4+ "github.com/charmbracelet/bubbles/key"
5+ "github.com/charmbracelet/bubbles/textarea"
6+ tea "github.com/charmbracelet/bubbletea"
7+ "github.com/charmbracelet/lipgloss"
8+ "github.com/opencode-ai/opencode/internal/logging"
9+ utilComponents "github.com/opencode-ai/opencode/internal/tui/components/util"
10+ "github.com/opencode-ai/opencode/internal/tui/layout"
11+ "github.com/opencode-ai/opencode/internal/tui/styles"
12+ "github.com/opencode-ai/opencode/internal/tui/theme"
13+ "github.com/opencode-ai/opencode/internal/tui/util"
14+)
15+
16+type CompletionItem struct {
17+ title string
18+ Title string
19+ Value string
20+}
21+
22+type CompletionItemI interface {
23+ utilComponents.SimpleListItem
24+ GetValue() string
25+ DisplayValue() string
26+}
27+
28+func (ci *CompletionItem) Render(selected bool, width int) string {
29+ t := theme.CurrentTheme()
30+ baseStyle := styles.BaseStyle()
31+
32+ itemStyle := baseStyle.
33+ Width(width).
34+ Padding(0, 1)
35+
36+ if selected {
37+ itemStyle = itemStyle.
38+ Background(t.Background()).
39+ Foreground(t.Primary()).
40+ Bold(true)
41+ }
42+
43+ title := itemStyle.Render(
44+ ci.GetValue(),
45+ )
46+
47+ return title
48+}
49+
50+func (ci *CompletionItem) DisplayValue() string {
51+ return ci.Title
52+}
53+
54+func (ci *CompletionItem) GetValue() string {
55+ return ci.Value
56+}
57+
58+func NewCompletionItem(completionItem CompletionItem) CompletionItemI {
59+ return &completionItem
60+}
61+
62+type CompletionProvider interface {
63+ GetId() string
64+ GetEntry() CompletionItemI
65+ GetChildEntries(query string) ([]CompletionItemI, error)
66+}
67+
68+type CompletionSelectedMsg struct {
69+ SearchString string
70+ CompletionValue string
71+}
72+
73+type CompletionDialogCompleteItemMsg struct {
74+ Value string
75+}
76+
77+type CompletionDialogCloseMsg struct{}
78+
79+type CompletionDialog interface {
80+ tea.Model
81+ layout.Bindings
82+ SetWidth(width int)
83+}
84+
85+type completionDialogCmp struct {
86+ query string
87+ completionProvider CompletionProvider
88+ width int
89+ height int
90+ pseudoSearchTextArea textarea.Model
91+ listView utilComponents.SimpleList[CompletionItemI]
92+}
93+
94+type completionDialogKeyMap struct {
95+ Complete key.Binding
96+ Cancel key.Binding
97+}
98+
99+var completionDialogKeys = completionDialogKeyMap{
100+ Complete: key.NewBinding(
101+ key.WithKeys("tab", "enter"),
102+ ),
103+ Cancel: key.NewBinding(
104+ key.WithKeys(" ", "esc", "backspace"),
105+ ),
106+}
107+
108+func (c *completionDialogCmp) Init() tea.Cmd {
109+ return nil
110+}
111+
112+func (c *completionDialogCmp) complete(item CompletionItemI) tea.Cmd {
113+ value := c.pseudoSearchTextArea.Value()
114+
115+ if value == "" {
116+ return nil
117+ }
118+
119+ return tea.Batch(
120+ util.CmdHandler(CompletionSelectedMsg{
121+ SearchString: value,
122+ CompletionValue: item.GetValue(),
123+ }),
124+ c.close(),
125+ )
126+}
127+
128+func (c *completionDialogCmp) close() tea.Cmd {
129+ c.listView.SetItems([]CompletionItemI{})
130+ c.pseudoSearchTextArea.Reset()
131+ c.pseudoSearchTextArea.Blur()
132+
133+ return util.CmdHandler(CompletionDialogCloseMsg{})
134+}
135+
136+func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
137+ var cmds []tea.Cmd
138+ switch msg := msg.(type) {
139+ case tea.KeyMsg:
140+ if c.pseudoSearchTextArea.Focused() {
141+
142+ if !key.Matches(msg, completionDialogKeys.Complete) {
143+
144+ var cmd tea.Cmd
145+ c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
146+ cmds = append(cmds, cmd)
147+
148+ var query string
149+ query = c.pseudoSearchTextArea.Value()
150+ if query != "" {
151+ query = query[1:]
152+ }
153+
154+ if query != c.query {
155+ logging.Info("Query", query)
156+ items, err := c.completionProvider.GetChildEntries(query)
157+ if err != nil {
158+ logging.Error("Failed to get child entries", err)
159+ }
160+
161+ c.listView.SetItems(items)
162+ c.query = query
163+ }
164+
165+ u, cmd := c.listView.Update(msg)
166+ c.listView = u.(utilComponents.SimpleList[CompletionItemI])
167+
168+ cmds = append(cmds, cmd)
169+ }
170+
171+ switch {
172+ case key.Matches(msg, completionDialogKeys.Complete):
173+ item, i := c.listView.GetSelectedItem()
174+ if i == -1 {
175+ return c, nil
176+ }
177+
178+ cmd := c.complete(item)
179+
180+ return c, cmd
181+ case key.Matches(msg, completionDialogKeys.Cancel):
182+ // Only close on backspace when there are no characters left
183+ if msg.String() != "backspace" || len(c.pseudoSearchTextArea.Value()) <= 0 {
184+ return c, c.close()
185+ }
186+ }
187+
188+ return c, tea.Batch(cmds...)
189+ } else {
190+ items, err := c.completionProvider.GetChildEntries("")
191+ if err != nil {
192+ logging.Error("Failed to get child entries", err)
193+ }
194+
195+ c.listView.SetItems(items)
196+ c.pseudoSearchTextArea.SetValue(msg.String())
197+ return c, c.pseudoSearchTextArea.Focus()
198+ }
199+ case tea.WindowSizeMsg:
200+ c.width = msg.Width
201+ c.height = msg.Height
202+ }
203+
204+ return c, tea.Batch(cmds...)
205+}
206+
207+func (c *completionDialogCmp) View() string {
208+ t := theme.CurrentTheme()
209+ baseStyle := styles.BaseStyle()
210+
211+ maxWidth := 40
212+
213+ completions := c.listView.GetItems()
214+
215+ for _, cmd := range completions {
216+ title := cmd.DisplayValue()
217+ if len(title) > maxWidth-4 {
218+ maxWidth = len(title) + 4
219+ }
220+ }
221+
222+ c.listView.SetMaxWidth(maxWidth)
223+
224+ return baseStyle.Padding(0, 0).
225+ Border(lipgloss.NormalBorder()).
226+ BorderBottom(false).
227+ BorderRight(false).
228+ BorderLeft(false).
229+ BorderBackground(t.Background()).
230+ BorderForeground(t.TextMuted()).
231+ Width(c.width).
232+ Render(c.listView.View())
233+}
234+
235+func (c *completionDialogCmp) SetWidth(width int) {
236+ c.width = width
237+}
238+
239+func (c *completionDialogCmp) BindingKeys() []key.Binding {
240+ return layout.KeyMapToSlice(completionDialogKeys)
241+}
242+
243+func NewCompletionDialogCmp(completionProvider CompletionProvider) CompletionDialog {
244+ ti := textarea.New()
245+
246+ items, err := completionProvider.GetChildEntries("")
247+ if err != nil {
248+ logging.Error("Failed to get child entries", err)
249+ }
250+
251+ li := utilComponents.NewSimpleList(
252+ items,
253+ 7,
254+ "No file matches found",
255+ false,
256+ )
257+
258+ return &completionDialogCmp{
259+ query: "",
260+ completionProvider: completionProvider,
261+ pseudoSearchTextArea: ti,
262+ listView: li,
263+ }
264+}
internal/tui/components/util/simple-list.gonew file+159
@@ -0,0 +1,159 @@
1+package utilComponents
2+
3+import (
4+ "github.com/charmbracelet/bubbles/key"
5+ tea "github.com/charmbracelet/bubbletea"
6+ "github.com/charmbracelet/lipgloss"
7+ "github.com/opencode-ai/opencode/internal/tui/layout"
8+ "github.com/opencode-ai/opencode/internal/tui/styles"
9+ "github.com/opencode-ai/opencode/internal/tui/theme"
10+)
11+
12+type SimpleListItem interface {
13+ Render(selected bool, width int) string
14+}
15+
16+type SimpleList[T SimpleListItem] interface {
17+ tea.Model
18+ layout.Bindings
19+ SetMaxWidth(maxWidth int)
20+ GetSelectedItem() (item T, idx int)
21+ SetItems(items []T)
22+ GetItems() []T
23+}
24+
25+type simpleListCmp[T SimpleListItem] struct {
26+ fallbackMsg string
27+ items []T
28+ selectedIdx int
29+ maxWidth int
30+ maxVisibleItems int
31+ useAlphaNumericKeys bool
32+ width int
33+ height int
34+}
35+
36+type simpleListKeyMap struct {
37+ Up key.Binding
38+ Down key.Binding
39+ UpAlpha key.Binding
40+ DownAlpha key.Binding
41+}
42+
43+var simpleListKeys = simpleListKeyMap{
44+ Up: key.NewBinding(
45+ key.WithKeys("up"),
46+ key.WithHelp("↑", "previous list item"),
47+ ),
48+ Down: key.NewBinding(
49+ key.WithKeys("down"),
50+ key.WithHelp("↓", "next list item"),
51+ ),
52+ UpAlpha: key.NewBinding(
53+ key.WithKeys("k"),
54+ key.WithHelp("k", "previous list item"),
55+ ),
56+ DownAlpha: key.NewBinding(
57+ key.WithKeys("j"),
58+ key.WithHelp("j", "next list item"),
59+ ),
60+}
61+
62+func (c *simpleListCmp[T]) Init() tea.Cmd {
63+ return nil
64+}
65+
66+func (c *simpleListCmp[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
67+ switch msg := msg.(type) {
68+ case tea.KeyMsg:
69+ switch {
70+ case key.Matches(msg, simpleListKeys.Up) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.UpAlpha)):
71+ if c.selectedIdx > 0 {
72+ c.selectedIdx--
73+ }
74+ return c, nil
75+ case key.Matches(msg, simpleListKeys.Down) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.DownAlpha)):
76+ if c.selectedIdx < len(c.items)-1 {
77+ c.selectedIdx++
78+ }
79+ return c, nil
80+ }
81+ }
82+
83+ return c, nil
84+}
85+
86+func (c *simpleListCmp[T]) BindingKeys() []key.Binding {
87+ return layout.KeyMapToSlice(simpleListKeys)
88+}
89+
90+func (c *simpleListCmp[T]) GetSelectedItem() (T, int) {
91+ if len(c.items) > 0 {
92+ return c.items[c.selectedIdx], c.selectedIdx
93+ }
94+
95+ var zero T
96+ return zero, -1
97+}
98+
99+func (c *simpleListCmp[T]) SetItems(items []T) {
100+ c.selectedIdx = 0
101+ c.items = items
102+}
103+
104+func (c *simpleListCmp[T]) GetItems() []T {
105+ return c.items
106+}
107+
108+func (c *simpleListCmp[T]) SetMaxWidth(width int) {
109+ c.maxWidth = width
110+}
111+
112+func (c *simpleListCmp[T]) View() string {
113+ t := theme.CurrentTheme()
114+ baseStyle := styles.BaseStyle()
115+
116+ items := c.items
117+ maxWidth := c.maxWidth
118+ maxVisibleItems := min(c.maxVisibleItems, len(items))
119+ startIdx := 0
120+
121+ if len(items) <= 0 {
122+ return baseStyle.
123+ Background(t.Background()).
124+ Padding(0, 1).
125+ Width(maxWidth).
126+ Render(c.fallbackMsg)
127+ }
128+
129+ if len(items) > maxVisibleItems {
130+ halfVisible := maxVisibleItems / 2
131+ if c.selectedIdx >= halfVisible && c.selectedIdx < len(items)-halfVisible {
132+ startIdx = c.selectedIdx - halfVisible
133+ } else if c.selectedIdx >= len(items)-halfVisible {
134+ startIdx = len(items) - maxVisibleItems
135+ }
136+ }
137+
138+ endIdx := min(startIdx+maxVisibleItems, len(items))
139+
140+ listItems := make([]string, 0, maxVisibleItems)
141+
142+ for i := startIdx; i < endIdx; i++ {
143+ item := items[i]
144+ title := item.Render(i == c.selectedIdx, maxWidth)
145+ listItems = append(listItems, title)
146+ }
147+
148+ return lipgloss.JoinVertical(lipgloss.Left, listItems...)
149+}
150+
151+func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) SimpleList[T] {
152+ return &simpleListCmp[T]{
153+ fallbackMsg: fallbackMsg,
154+ items: items,
155+ maxVisibleItems: maxVisibleItems,
156+ useAlphaNumericKeys: useAlphaNumericKeys,
157+ selectedIdx: 0,
158+ }
159+}
internal/tui/page/chat.go+62−11
@@ -6,7 +6,9 @@ import (
66
77 "github.com/charmbracelet/bubbles/key"
88 tea "github.com/charmbracelet/bubbletea"
9+ "github.com/charmbracelet/lipgloss"
910 "github.com/opencode-ai/opencode/internal/app"
11+ "github.com/opencode-ai/opencode/internal/completions"
1012 "github.com/opencode-ai/opencode/internal/message"
1113 "github.com/opencode-ai/opencode/internal/session"
1214 "github.com/opencode-ai/opencode/internal/tui/components/chat"
@@ -18,19 +20,26 @@ import (
1820var ChatPage PageID = "chat"
1921
2022type chatPage struct {
21− app *app.App
22− editor layout.Container
23− messages layout.Container
24− layout layout.SplitPaneLayout
25− session session.Session
23+ app *app.App
24+ editor layout.Container
25+ messages layout.Container
26+ layout layout.SplitPaneLayout
27+ session session.Session
28+ completionDialog dialog.CompletionDialog
29+ showCompletionDialog bool
2630}
2731
2832type ChatKeyMap struct {
29− NewSession key.Binding
30− Cancel key.Binding
33+ ShowCompletionDialog key.Binding
34+ NewSession key.Binding
35+ Cancel key.Binding
3136}
3237
3338var keyMap = ChatKeyMap{
39+ ShowCompletionDialog: key.NewBinding(
40+ key.WithKeys("@"),
41+ key.WithHelp("@", "Complete"),
42+ ),
3443 NewSession: key.NewBinding(
3544 key.WithKeys("ctrl+n"),
3645 key.WithHelp("ctrl+n", "new session"),
@@ -44,6 +53,7 @@ var keyMap = ChatKeyMap{
4453func (p *chatPage) Init() tea.Cmd {
4554 cmds := []tea.Cmd{
4655 p.layout.Init(),
56+ p.completionDialog.Init(),
4757 }
4858 return tea.Batch(cmds...)
4959}
@@ -54,6 +64,8 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
5464 case tea.WindowSizeMsg:
5565 cmd := p.layout.SetSize(msg.Width, msg.Height)
5666 cmds = append(cmds, cmd)
67+ case dialog.CompletionDialogCloseMsg:
68+ p.showCompletionDialog = false
5769 case chat.SendMsg:
5870 cmd := p.sendMessage(msg.Text, msg.Attachments)
5971 if cmd != nil {
@@ -90,6 +102,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
90102 p.session = msg
91103 case tea.KeyMsg:
92104 switch {
105+ case key.Matches(msg, keyMap.ShowCompletionDialog):
106+ p.showCompletionDialog = true
107+ // Continue sending keys to layout->chat
93108 case key.Matches(msg, keyMap.NewSession):
94109 p.session = session.Session{}
95110 return p, tea.Batch(
@@ -105,9 +120,23 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
105120 }
106121 }
107122 }
123+ if p.showCompletionDialog {
124+ context, contextCmd := p.completionDialog.Update(msg)
125+ p.completionDialog = context.(dialog.CompletionDialog)
126+ cmds = append(cmds, contextCmd)
127+
128+ // Doesn't forward event if enter key is pressed
129+ if keyMsg, ok := msg.(tea.KeyMsg); ok {
130+ if keyMsg.String() == "enter" {
131+ return p, tea.Batch(cmds...)
132+ }
133+ }
134+ }
135+
108136 u, cmd := p.layout.Update(msg)
109137 cmds = append(cmds, cmd)
110138 p.layout = u.(layout.SplitPaneLayout)
139+
111140 return p, tea.Batch(cmds...)
112141}
113142
@@ -155,7 +184,25 @@ func (p *chatPage) GetSize() (int, int) {
155184}
156185
157186func (p *chatPage) View() string {
158− return p.layout.View()
187+ layoutView := p.layout.View()
188+
189+ if p.showCompletionDialog {
190+ _, layoutHeight := p.layout.GetSize()
191+ editorWidth, editorHeight := p.editor.GetSize()
192+
193+ p.completionDialog.SetWidth(editorWidth)
194+ overlay := p.completionDialog.View()
195+
196+ layoutView = layout.PlaceOverlay(
197+ 0,
198+ layoutHeight-editorHeight-lipgloss.Height(overlay),
199+ overlay,
200+ layoutView,
201+ false,
202+ )
203+ }
204+
205+ return layoutView
159206}
160207
161208func (p *chatPage) BindingKeys() []key.Binding {
@@ -166,6 +213,9 @@ func (p *chatPage) BindingKeys() []key.Binding {
166213}
167214
168215func NewChatPage(app *app.App) tea.Model {
216+ cg := completions.NewFileAndFolderContextGroup()
217+ completionDialog := dialog.NewCompletionDialogCmp(cg)
218+
169219 messagesContainer := layout.NewContainer(
170220 chat.NewMessagesCmp(app),
171221 layout.WithPadding(1, 1, 0, 1),
@@ -175,9 +225,10 @@ func NewChatPage(app *app.App) tea.Model {
175225 layout.WithBorder(true, false, false, false),
176226 )
177227 return &chatPage{
178− app: app,
179− editor: editorContainer,
180− messages: messagesContainer,
228+ app: app,
229+ editor: editorContainer,
230+ messages: messagesContainer,
231+ completionDialog: completionDialog,
181232 layout: layout.NewSplitPane(
182233 layout.WithLeftPanel(messagesContainer),
183234 layout.WithBottomPanel(editorContainer),