on main help sign in
implement completions
commitb37ff5fec5a67699465969a3c034210da1d068a7 authorKujtim Hoxha <kujtimii.h@gmail.com> dateFri 30 May 2025, 21:54 (1 year ago) parent6f12143
23 files changed+1009−2068
go.mod+2−1 go.sum+6−2 internal/completions/files-folders.go−191 internal/fileutil/fileutil.go+71−30 internal/fileutil/ls.go+169 internal/llm/tools/glob.go+1−1 internal/llm/tools/ls.go+2−96 internal/llm/tools/ls_test.go−457 internal/tui/components/chat/editor.go → internal/tui/components/chat/editor/editor.go+106−36 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+23−15 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+5−4 internal/tui/components/dialogs/commands/item.go−145 internal/tui/page/chat.go+17−85 internal/tui/tui.go+53−16
go.mod+2−1
@@ -12,6 +12,7 @@ require (
1212 github.com/aymanbagabas/go-udiff v0.2.0
1313 github.com/bmatcuk/doublestar/v4 v4.8.1
1414 github.com/catppuccin/go v0.3.0
15+ github.com/charlievieth/fastwalk v1.0.11
1516 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318
1617 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c
1718 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
@@ -27,6 +28,7 @@ require (
2728 github.com/ncruces/go-sqlite3 v0.25.0
2829 github.com/openai/openai-go v0.1.0-beta.2
2930 github.com/pressly/goose/v3 v3.24.2
31+ github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
3032 github.com/sahilm/fuzzy v0.1.1
3133 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
3234 github.com/spf13/cobra v1.9.1
@@ -81,7 +83,6 @@ require (
8183 github.com/gorilla/websocket v1.5.3 // indirect
8284 github.com/inconshreveable/mousetrap v1.1.0 // indirect
8385 github.com/kylelemons/godebug v1.1.0 // indirect
84− github.com/lithammer/fuzzysearch v1.1.8
8586 github.com/lucasb-eyer/go-colorful v1.2.0
8687 github.com/mattn/go-isatty v0.0.20 // indirect
8788 github.com/mattn/go-runewidth v0.0.16 // indirect
go.sum+6−2
@@ -68,6 +68,8 @@ github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR
6868github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
6969github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
7070github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
71+github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8=
72+github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
7173github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318 h1:f8Q0ybZGxT+St1JfPM7yoz/XFpbmtodcIehaom/9XT8=
7274github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
7375github.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=
148150github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
149151github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
150152github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
151−github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
152−github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
153153github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
154154github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
155155github.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
197197github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
198198github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
199199github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
200+github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
201+github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
200202github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
201203github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
202204github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
@@ -224,6 +226,7 @@ github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqj
224226github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
225227github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
226228github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
229+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
227230github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
228231github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
229232github.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
348351gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
349352gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
350353gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
354+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
351355gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
352356gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
353357modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
internal/completions/files-folders.godeleted−191
@@ -1,191 +0,0 @@
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.go+71−30
@@ -2,7 +2,6 @@ package fileutil
22
33import (
44 "fmt"
5− "io/fs"
65 "os"
76 "os/exec"
87 "path/filepath"
@@ -11,7 +10,9 @@ import (
1110 "time"
1211
1312 "github.com/bmatcuk/doublestar/v4"
13+ "github.com/charlievieth/fastwalk"
1414 "github.com/opencode-ai/opencode/internal/logging"
15+ ignore "github.com/sabhiram/go-gitignore"
1516)
1617
1718var (
@@ -53,21 +54,6 @@ func GetRgCmd(globPattern string) *exec.Cmd {
5354 return cmd
5455}
5556
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−
7157type FileInfo struct {
7258 Path string
7359 ModTime time.Time
@@ -112,37 +98,92 @@ func SkipHidden(path string) bool {
11298 return false
11399}
114100
115−func GlobWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) {
116− fsys := os.DirFS(searchPath)
117− relPattern := strings.TrimPrefix(pattern, "/")
101+// FastGlobWalker provides gitignore-aware file walking with fastwalk
102+type FastGlobWalker struct {
103+ gitignore *ignore.GitIgnore
104+ rootPath string
105+}
106+
107+func NewFastGlobWalker(searchPath string) *FastGlobWalker {
108+ walker := &FastGlobWalker{
109+ rootPath: searchPath,
110+ }
111+
112+ // Load gitignore if it exists
113+ gitignorePath := filepath.Join(searchPath, ".gitignore")
114+ if _, err := os.Stat(gitignorePath); err == nil {
115+ if gi, err := ignore.CompileIgnoreFile(gitignorePath); err == nil {
116+ walker.gitignore = gi
117+ }
118+ }
119+
120+ return walker
121+}
122+
123+func (w *FastGlobWalker) shouldSkip(path string) bool {
124+ if SkipHidden(path) {
125+ return true
126+ }
127+
128+ if w.gitignore != nil {
129+ relPath, err := filepath.Rel(w.rootPath, path)
130+ if err == nil && w.gitignore.MatchesPath(relPath) {
131+ return true
132+ }
133+ }
134+
135+ return false
136+}
137+
138+func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool, error) {
139+ walker := NewFastGlobWalker(searchPath)
118140 var matches []FileInfo
141+ conf := fastwalk.Config{
142+ Follow: true,
143+ // Use forward slashes when running a Windows binary under WSL or MSYS
144+ ToSlash: fastwalk.DefaultToSlash(),
145+ Sort: fastwalk.SortFilesFirst,
146+ }
147+ err := fastwalk.Walk(&conf, searchPath, func(path string, d os.DirEntry, err error) error {
148+ if err != nil {
149+ return nil // Skip files we can't access
150+ }
119151
120− err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
121152 if d.IsDir() {
153+ if walker.shouldSkip(path) {
154+ return filepath.SkipDir
155+ }
122156 return nil
123157 }
124− if SkipHidden(path) {
158+
159+ if walker.shouldSkip(path) {
125160 return nil
126161 }
127− info, err := d.Info()
162+
163+ // Check if path matches the pattern
164+ relPath, err := filepath.Rel(searchPath, path)
128165 if err != nil {
166+ relPath = path
167+ }
168+
169+ matched, err := doublestar.Match(pattern, relPath)
170+ if err != nil || !matched {
129171 return nil
130172 }
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
173+
174+ info, err := d.Info()
175+ if err != nil {
176+ return nil
136177 }
137178
138− matches = append(matches, FileInfo{Path: absPath, ModTime: info.ModTime()})
179+ matches = append(matches, FileInfo{Path: path, ModTime: info.ModTime()})
139180 if limit > 0 && len(matches) >= limit*2 {
140− return fs.SkipAll
181+ return filepath.SkipAll
141182 }
142183 return nil
143184 })
144185 if err != nil {
145− return nil, false, fmt.Errorf("glob walk error: %w", err)
186+ return nil, false, fmt.Errorf("fastwalk error: %w", err)
146187 }
147188
148189 sort.Slice(matches, func(i, j int) bool {
internal/fileutil/ls.gonew file+169
@@ -0,0 +1,169 @@
1+package fileutil
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+
8+ "github.com/charlievieth/fastwalk"
9+ ignore "github.com/sabhiram/go-gitignore"
10+)
11+
12+// CommonIgnorePatterns contains commonly ignored files and directories
13+var CommonIgnorePatterns = []string{
14+ // Version control
15+ ".git",
16+ ".svn",
17+ ".hg",
18+ ".bzr",
19+
20+ // IDE and editor files
21+ ".vscode",
22+ ".idea",
23+ "*.swp",
24+ "*.swo",
25+ "*~",
26+ ".DS_Store",
27+ "Thumbs.db",
28+
29+ // Build artifacts and dependencies
30+ "node_modules",
31+ "target",
32+ "build",
33+ "dist",
34+ "out",
35+ "bin",
36+ "obj",
37+ "*.o",
38+ "*.so",
39+ "*.dylib",
40+ "*.dll",
41+ "*.exe",
42+
43+ // Logs and temporary files
44+ "*.log",
45+ "*.tmp",
46+ "*.temp",
47+ ".cache",
48+ ".tmp",
49+
50+ // Language-specific
51+ "__pycache__",
52+ "*.pyc",
53+ "*.pyo",
54+ ".pytest_cache",
55+ "vendor",
56+ "Cargo.lock",
57+ "package-lock.json",
58+ "yarn.lock",
59+ "pnpm-lock.yaml",
60+
61+ // OS generated files
62+ ".Trash",
63+ ".Spotlight-V100",
64+ ".fseventsd",
65+
66+ // OpenCode
67+ ".opencode",
68+}
69+
70+type DirectoryLister struct {
71+ gitignore *ignore.GitIgnore
72+ commonIgnore *ignore.GitIgnore
73+ rootPath string
74+}
75+
76+func NewDirectoryLister(rootPath string) *DirectoryLister {
77+ dl := &DirectoryLister{
78+ rootPath: rootPath,
79+ }
80+
81+ // Load gitignore if it exists
82+ gitignorePath := filepath.Join(rootPath, ".gitignore")
83+ if _, err := os.Stat(gitignorePath); err == nil {
84+ if gi, err := ignore.CompileIgnoreFile(gitignorePath); err == nil {
85+ dl.gitignore = gi
86+ }
87+ }
88+
89+ // Create common ignore patterns
90+ dl.commonIgnore = ignore.CompileIgnoreLines(CommonIgnorePatterns...)
91+
92+ return dl
93+}
94+
95+func (dl *DirectoryLister) shouldIgnore(path string, ignorePatterns []string) bool {
96+ relPath, err := filepath.Rel(dl.rootPath, path)
97+ if err != nil {
98+ relPath = path
99+ }
100+
101+ // Check common ignore patterns
102+ if dl.commonIgnore.MatchesPath(relPath) {
103+ return true
104+ }
105+
106+ // Check gitignore patterns if available
107+ if dl.gitignore != nil && dl.gitignore.MatchesPath(relPath) {
108+ return true
109+ }
110+
111+ base := filepath.Base(path)
112+
113+ if base != "." && strings.HasPrefix(base, ".") {
114+ return true
115+ }
116+
117+ for _, pattern := range ignorePatterns {
118+ matched, err := filepath.Match(pattern, base)
119+ if err == nil && matched {
120+ return true
121+ }
122+ }
123+ return false
124+}
125+
126+// ListDirectory lists files and directories in the specified path,
127+func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
128+ var results []string
129+ truncated := false
130+ dl := NewDirectoryLister(initialPath)
131+
132+ conf := fastwalk.Config{
133+ Follow: true,
134+ // Use forward slashes when running a Windows binary under WSL or MSYS
135+ ToSlash: fastwalk.DefaultToSlash(),
136+ Sort: fastwalk.SortDirsFirst,
137+ }
138+ err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error {
139+ if err != nil {
140+ return nil // Skip files we don't have permission to access
141+ }
142+
143+ if dl.shouldIgnore(path, ignorePatterns) {
144+ if d.IsDir() {
145+ return filepath.SkipDir
146+ }
147+ return nil
148+ }
149+
150+ if path != initialPath {
151+ if d.IsDir() {
152+ path = path + string(filepath.Separator)
153+ }
154+ results = append(results, path)
155+ }
156+
157+ if limit > 0 && len(results) >= limit {
158+ truncated = true
159+ return filepath.SkipAll
160+ }
161+
162+ return nil
163+ })
164+ if err != nil {
165+ return nil, truncated, err
166+ }
167+
168+ return results, truncated, nil
169+}
internal/llm/tools/glob.go+1−1
@@ -137,7 +137,7 @@ func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
137137 logging.Warn(fmt.Sprintf("Ripgrep execution failed: %v. Falling back to doublestar.", err))
138138 }
139139
140− return fileutil.GlobWithDoublestar(pattern, searchPath, limit)
140+ return fileutil.GlobWithDoubleStar(pattern, searchPath, limit)
141141}
142142
143143func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) {
internal/llm/tools/ls.go+2−96
@@ -9,6 +9,7 @@ import (
99 "strings"
1010
1111 "github.com/opencode-ai/opencode/internal/config"
12+ "github.com/opencode-ai/opencode/internal/fileutil"
1213)
1314
1415type LSParams struct {
@@ -107,7 +108,7 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
107108 return NewTextErrorResponse(fmt.Sprintf("path does not exist: %s", searchPath)), nil
108109 }
109110
110− files, truncated, err := listDirectory(searchPath, params.Ignore, MaxLSFiles)
111+ files, truncated, err := fileutil.ListDirectory(searchPath, params.Ignore, MaxLSFiles)
111112 if err != nil {
112113 return ToolResponse{}, fmt.Errorf("error listing directory: %w", err)
113114 }
@@ -128,101 +129,6 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
128129 ), nil
129130}
130131
131−func listDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
132− var results []string
133− truncated := false
134−
135− err := filepath.Walk(initialPath, func(path string, info os.FileInfo, err error) error {
136− if err != nil {
137− return nil // Skip files we don't have permission to access
138− }
139−
140− if shouldSkip(path, ignorePatterns) {
141− if info.IsDir() {
142− return filepath.SkipDir
143− }
144− return nil
145− }
146−
147− if path != initialPath {
148− if info.IsDir() {
149− path = path + string(filepath.Separator)
150− }
151− results = append(results, path)
152− }
153−
154− if len(results) >= limit {
155− truncated = true
156− return filepath.SkipAll
157− }
158−
159− return nil
160− })
161− if err != nil {
162− return nil, truncated, err
163− }
164−
165− return results, truncated, nil
166−}
167−
168−func shouldSkip(path string, ignorePatterns []string) bool {
169− base := filepath.Base(path)
170−
171− if base != "." && strings.HasPrefix(base, ".") {
172− return true
173− }
174−
175− commonIgnored := []string{
176− "__pycache__",
177− "node_modules",
178− "dist",
179− "build",
180− "target",
181− "vendor",
182− "bin",
183− "obj",
184− ".git",
185− ".idea",
186− ".vscode",
187− ".DS_Store",
188− "*.pyc",
189− "*.pyo",
190− "*.pyd",
191− "*.so",
192− "*.dll",
193− "*.exe",
194− }
195−
196− if strings.Contains(path, filepath.Join("__pycache__", "")) {
197− return true
198− }
199−
200− for _, ignored := range commonIgnored {
201− if strings.HasSuffix(ignored, "/") {
202− if strings.Contains(path, filepath.Join(ignored[:len(ignored)-1], "")) {
203− return true
204− }
205− } else if strings.HasPrefix(ignored, "*.") {
206− if strings.HasSuffix(base, ignored[1:]) {
207− return true
208− }
209− } else {
210− if base == ignored {
211− return true
212− }
213− }
214− }
215−
216− for _, pattern := range ignorePatterns {
217− matched, err := filepath.Match(pattern, base)
218− if err == nil && matched {
219− return true
220− }
221− }
222−
223− return false
224−}
225−
226132func createFileTree(sortedPaths []string) []*TreeNode {
227133 root := []*TreeNode{}
228134 pathMap := make(map[string]*TreeNode)
internal/llm/tools/ls_test.godeleted−457
@@ -1,457 +0,0 @@
1−package tools
2−
3−import (
4− "context"
5− "encoding/json"
6− "os"
7− "path/filepath"
8− "strings"
9− "testing"
10−
11− "github.com/stretchr/testify/assert"
12− "github.com/stretchr/testify/require"
13−)
14−
15−func TestLsTool_Info(t *testing.T) {
16− tool := NewLsTool()
17− info := tool.Info()
18−
19− assert.Equal(t, LSToolName, info.Name)
20− assert.NotEmpty(t, info.Description)
21− assert.Contains(t, info.Parameters, "path")
22− assert.Contains(t, info.Parameters, "ignore")
23− assert.Contains(t, info.Required, "path")
24−}
25−
26−func TestLsTool_Run(t *testing.T) {
27− // Create a temporary directory for testing
28− tempDir, err := os.MkdirTemp("", "ls_tool_test")
29− require.NoError(t, err)
30− defer os.RemoveAll(tempDir)
31−
32− // Create a test directory structure
33− testDirs := []string{
34− "dir1",
35− "dir2",
36− "dir2/subdir1",
37− "dir2/subdir2",
38− "dir3",
39− "dir3/.hidden_dir",
40− "__pycache__",
41− }
42−
43− testFiles := []string{
44− "file1.txt",
45− "file2.txt",
46− "dir1/file3.txt",
47− "dir2/file4.txt",
48− "dir2/subdir1/file5.txt",
49− "dir2/subdir2/file6.txt",
50− "dir3/file7.txt",
51− "dir3/.hidden_file.txt",
52− "__pycache__/cache.pyc",
53− ".hidden_root_file.txt",
54− }
55−
56− // Create directories
57− for _, dir := range testDirs {
58− dirPath := filepath.Join(tempDir, dir)
59− err := os.MkdirAll(dirPath, 0o755)
60− require.NoError(t, err)
61− }
62−
63− // Create files
64− for _, file := range testFiles {
65− filePath := filepath.Join(tempDir, file)
66− err := os.WriteFile(filePath, []byte("test content"), 0o644)
67− require.NoError(t, err)
68− }
69−
70− t.Run("lists directory successfully", func(t *testing.T) {
71− tool := NewLsTool()
72− params := LSParams{
73− Path: tempDir,
74− }
75−
76− paramsJSON, err := json.Marshal(params)
77− require.NoError(t, err)
78−
79− call := ToolCall{
80− Name: LSToolName,
81− Input: string(paramsJSON),
82− }
83−
84− response, err := tool.Run(context.Background(), call)
85− require.NoError(t, err)
86−
87− // Check that visible directories and files are included
88− assert.Contains(t, response.Content, "dir1")
89− assert.Contains(t, response.Content, "dir2")
90− assert.Contains(t, response.Content, "dir3")
91− assert.Contains(t, response.Content, "file1.txt")
92− assert.Contains(t, response.Content, "file2.txt")
93−
94− // Check that hidden files and directories are not included
95− assert.NotContains(t, response.Content, ".hidden_dir")
96− assert.NotContains(t, response.Content, ".hidden_file.txt")
97− assert.NotContains(t, response.Content, ".hidden_root_file.txt")
98−
99− // Check that __pycache__ is not included
100− assert.NotContains(t, response.Content, "__pycache__")
101− })
102−
103− t.Run("handles non-existent path", func(t *testing.T) {
104− tool := NewLsTool()
105− params := LSParams{
106− Path: filepath.Join(tempDir, "non_existent_dir"),
107− }
108−
109− paramsJSON, err := json.Marshal(params)
110− require.NoError(t, err)
111−
112− call := ToolCall{
113− Name: LSToolName,
114− Input: string(paramsJSON),
115− }
116−
117− response, err := tool.Run(context.Background(), call)
118− require.NoError(t, err)
119− assert.Contains(t, response.Content, "path does not exist")
120− })
121−
122− t.Run("handles empty path parameter", func(t *testing.T) {
123− // For this test, we need to mock the config.WorkingDirectory function
124− // Since we can't easily do that, we'll just check that the response doesn't contain an error message
125−
126− tool := NewLsTool()
127− params := LSParams{
128− Path: "",
129− }
130−
131− paramsJSON, err := json.Marshal(params)
132− require.NoError(t, err)
133−
134− call := ToolCall{
135− Name: LSToolName,
136− Input: string(paramsJSON),
137− }
138−
139− response, err := tool.Run(context.Background(), call)
140− require.NoError(t, err)
141−
142− // The response should either contain a valid directory listing or an error
143− // We'll just check that it's not empty
144− assert.NotEmpty(t, response.Content)
145− })
146−
147− t.Run("handles invalid parameters", func(t *testing.T) {
148− tool := NewLsTool()
149− call := ToolCall{
150− Name: LSToolName,
151− Input: "invalid json",
152− }
153−
154− response, err := tool.Run(context.Background(), call)
155− require.NoError(t, err)
156− assert.Contains(t, response.Content, "error parsing parameters")
157− })
158−
159− t.Run("respects ignore patterns", func(t *testing.T) {
160− tool := NewLsTool()
161− params := LSParams{
162− Path: tempDir,
163− Ignore: []string{"file1.txt", "dir1"},
164− }
165−
166− paramsJSON, err := json.Marshal(params)
167− require.NoError(t, err)
168−
169− call := ToolCall{
170− Name: LSToolName,
171− Input: string(paramsJSON),
172− }
173−
174− response, err := tool.Run(context.Background(), call)
175− require.NoError(t, err)
176−
177− // The output format is a tree, so we need to check for specific patterns
178− // Check that file1.txt is not directly mentioned
179− assert.NotContains(t, response.Content, "- file1.txt")
180−
181− // Check that dir1/ is not directly mentioned
182− assert.NotContains(t, response.Content, "- dir1/")
183− })
184−
185− t.Run("handles relative path", func(t *testing.T) {
186− // Save original working directory
187− origWd, err := os.Getwd()
188− require.NoError(t, err)
189− defer func() {
190− os.Chdir(origWd)
191− }()
192−
193− // Change to a directory above the temp directory
194− parentDir := filepath.Dir(tempDir)
195− err = os.Chdir(parentDir)
196− require.NoError(t, err)
197−
198− tool := NewLsTool()
199− params := LSParams{
200− Path: filepath.Base(tempDir),
201− }
202−
203− paramsJSON, err := json.Marshal(params)
204− require.NoError(t, err)
205−
206− call := ToolCall{
207− Name: LSToolName,
208− Input: string(paramsJSON),
209− }
210−
211− response, err := tool.Run(context.Background(), call)
212− require.NoError(t, err)
213−
214− // Should list the temp directory contents
215− assert.Contains(t, response.Content, "dir1")
216− assert.Contains(t, response.Content, "file1.txt")
217− })
218−}
219−
220−func TestShouldSkip(t *testing.T) {
221− testCases := []struct {
222− name string
223− path string
224− ignorePatterns []string
225− expected bool
226− }{
227− {
228− name: "hidden file",
229− path: "/path/to/.hidden_file",
230− ignorePatterns: []string{},
231− expected: true,
232− },
233− {
234− name: "hidden directory",
235− path: "/path/to/.hidden_dir",
236− ignorePatterns: []string{},
237− expected: true,
238− },
239− {
240− name: "pycache directory",
241− path: "/path/to/__pycache__/file.pyc",
242− ignorePatterns: []string{},
243− expected: true,
244− },
245− {
246− name: "node_modules directory",
247− path: "/path/to/node_modules/package",
248− ignorePatterns: []string{},
249− expected: false, // The shouldSkip function doesn't directly check for node_modules in the path
250− },
251− {
252− name: "normal file",
253− path: "/path/to/normal_file.txt",
254− ignorePatterns: []string{},
255− expected: false,
256− },
257− {
258− name: "normal directory",
259− path: "/path/to/normal_dir",
260− ignorePatterns: []string{},
261− expected: false,
262− },
263− {
264− name: "ignored by pattern",
265− path: "/path/to/ignore_me.txt",
266− ignorePatterns: []string{"ignore_*.txt"},
267− expected: true,
268− },
269− {
270− name: "not ignored by pattern",
271− path: "/path/to/keep_me.txt",
272− ignorePatterns: []string{"ignore_*.txt"},
273− expected: false,
274− },
275− }
276−
277− for _, tc := range testCases {
278− t.Run(tc.name, func(t *testing.T) {
279− result := shouldSkip(tc.path, tc.ignorePatterns)
280− assert.Equal(t, tc.expected, result)
281− })
282− }
283−}
284−
285−func TestCreateFileTree(t *testing.T) {
286− paths := []string{
287− "/path/to/file1.txt",
288− "/path/to/dir1/file2.txt",
289− "/path/to/dir1/subdir/file3.txt",
290− "/path/to/dir2/file4.txt",
291− }
292−
293− tree := createFileTree(paths)
294−
295− // Check the structure of the tree
296− assert.Len(t, tree, 1) // Should have one root node
297−
298− // Check the root node
299− rootNode := tree[0]
300− assert.Equal(t, "path", rootNode.Name)
301− assert.Equal(t, "directory", rootNode.Type)
302− assert.Len(t, rootNode.Children, 1)
303−
304− // Check the "to" node
305− toNode := rootNode.Children[0]
306− assert.Equal(t, "to", toNode.Name)
307− assert.Equal(t, "directory", toNode.Type)
308− assert.Len(t, toNode.Children, 3) // file1.txt, dir1, dir2
309−
310− // Find the dir1 node
311− var dir1Node *TreeNode
312− for _, child := range toNode.Children {
313− if child.Name == "dir1" {
314− dir1Node = child
315− break
316− }
317− }
318−
319− require.NotNil(t, dir1Node)
320− assert.Equal(t, "directory", dir1Node.Type)
321− assert.Len(t, dir1Node.Children, 2) // file2.txt and subdir
322−}
323−
324−func TestPrintTree(t *testing.T) {
325− // Create a simple tree
326− tree := []*TreeNode{
327− {
328− Name: "dir1",
329− Path: "dir1",
330− Type: "directory",
331− Children: []*TreeNode{
332− {
333− Name: "file1.txt",
334− Path: "dir1/file1.txt",
335− Type: "file",
336− },
337− {
338− Name: "subdir",
339− Path: "dir1/subdir",
340− Type: "directory",
341− Children: []*TreeNode{
342− {
343− Name: "file2.txt",
344− Path: "dir1/subdir/file2.txt",
345− Type: "file",
346− },
347− },
348− },
349− },
350− },
351− {
352− Name: "file3.txt",
353− Path: "file3.txt",
354− Type: "file",
355− },
356− }
357−
358− result := printTree(tree, "/root")
359−
360− // Check the output format
361− assert.Contains(t, result, "- /root/")
362− assert.Contains(t, result, " - dir1/")
363− assert.Contains(t, result, " - file1.txt")
364− assert.Contains(t, result, " - subdir/")
365− assert.Contains(t, result, " - file2.txt")
366− assert.Contains(t, result, " - file3.txt")
367−}
368−
369−func TestListDirectory(t *testing.T) {
370− // Create a temporary directory for testing
371− tempDir, err := os.MkdirTemp("", "list_directory_test")
372− require.NoError(t, err)
373− defer os.RemoveAll(tempDir)
374−
375− // Create a test directory structure
376− testDirs := []string{
377− "dir1",
378− "dir1/subdir1",
379− ".hidden_dir",
380− }
381−
382− testFiles := []string{
383− "file1.txt",
384− "file2.txt",
385− "dir1/file3.txt",
386− "dir1/subdir1/file4.txt",
387− ".hidden_file.txt",
388− }
389−
390− // Create directories
391− for _, dir := range testDirs {
392− dirPath := filepath.Join(tempDir, dir)
393− err := os.MkdirAll(dirPath, 0o755)
394− require.NoError(t, err)
395− }
396−
397− // Create files
398− for _, file := range testFiles {
399− filePath := filepath.Join(tempDir, file)
400− err := os.WriteFile(filePath, []byte("test content"), 0o644)
401− require.NoError(t, err)
402− }
403−
404− t.Run("lists files with no limit", func(t *testing.T) {
405− files, truncated, err := listDirectory(tempDir, []string{}, 1000)
406− require.NoError(t, err)
407− assert.False(t, truncated)
408−
409− // Check that visible files and directories are included
410− containsPath := func(paths []string, target string) bool {
411− targetPath := filepath.Join(tempDir, target)
412− for _, path := range paths {
413− if strings.HasPrefix(path, targetPath) {
414− return true
415− }
416− }
417− return false
418− }
419−
420− assert.True(t, containsPath(files, "dir1"))
421− assert.True(t, containsPath(files, "file1.txt"))
422− assert.True(t, containsPath(files, "file2.txt"))
423− assert.True(t, containsPath(files, "dir1/file3.txt"))
424−
425− // Check that hidden files and directories are not included
426− assert.False(t, containsPath(files, ".hidden_dir"))
427− assert.False(t, containsPath(files, ".hidden_file.txt"))
428− })
429−
430− t.Run("respects limit and returns truncated flag", func(t *testing.T) {
431− files, truncated, err := listDirectory(tempDir, []string{}, 2)
432− require.NoError(t, err)
433− assert.True(t, truncated)
434− assert.Len(t, files, 2)
435− })
436−
437− t.Run("respects ignore patterns", func(t *testing.T) {
438− files, truncated, err := listDirectory(tempDir, []string{"*.txt"}, 1000)
439− require.NoError(t, err)
440− assert.False(t, truncated)
441−
442− // Check that no .txt files are included
443− for _, file := range files {
444− assert.False(t, strings.HasSuffix(file, ".txt"), "Found .txt file: %s", file)
445− }
446−
447− // But directories should still be included
448− containsDir := false
449− for _, file := range files {
450− if strings.Contains(file, "dir1") {
451− containsDir = true
452− break
453− }
454− }
455− assert.True(t, containsDir)
456− })
457−}
internal/tui/components/chat/editor.go → internal/tui/components/chat/editor/editor.gorenamed+106−36
@@ -1,4 +1,4 @@
1−package chat
1+package editor
22
33import (
44 "fmt"
@@ -13,9 +13,12 @@ import (
1313 tea "github.com/charmbracelet/bubbletea/v2"
1414 "github.com/charmbracelet/lipgloss/v2"
1515 "github.com/opencode-ai/opencode/internal/app"
16+ "github.com/opencode-ai/opencode/internal/fileutil"
1617 "github.com/opencode-ai/opencode/internal/logging"
1718 "github.com/opencode-ai/opencode/internal/message"
1819 "github.com/opencode-ai/opencode/internal/session"
20+ "github.com/opencode-ai/opencode/internal/tui/components/chat"
21+ "github.com/opencode-ai/opencode/internal/tui/components/completions"
1922 "github.com/opencode-ai/opencode/internal/tui/components/dialog"
2023 "github.com/opencode-ai/opencode/internal/tui/layout"
2124 "github.com/opencode-ai/opencode/internal/tui/styles"
@@ -23,6 +26,10 @@ import (
2326 "github.com/opencode-ai/opencode/internal/tui/util"
2427)
2528
29+type FileCompletionItem struct {
30+ Path string // The file path
31+}
32+
2633type editorCmp struct {
2734 width int
2835 height int
@@ -32,35 +39,21 @@ type editorCmp struct {
3239 textarea textarea.Model
3340 attachments []message.Attachment
3441 deleteMode bool
35−}
3642
37−type EditorKeyMaps struct {
38− Send key.Binding
39− OpenEditor key.Binding
40−}
43+ keyMap EditorKeyMap
4144
42−type bluredEditorKeyMaps struct {
43− Send key.Binding
44− Focus key.Binding
45− OpenEditor key.Binding
45+ // File path completions
46+ currentQuery string
47+ completionsStartIndex int
48+ isCompletionsOpen bool
4649}
50+
4751type DeleteAttachmentKeyMaps struct {
4852 AttachmentDeleteMode key.Binding
4953 Escape key.Binding
5054 DeleteAllAttachments key.Binding
5155}
5256
53−var editorMaps = EditorKeyMaps{
54− Send: key.NewBinding(
55− key.WithKeys("enter", "ctrl+s"),
56− key.WithHelp("enter", "send message"),
57− ),
58− OpenEditor: key.NewBinding(
59− key.WithKeys("ctrl+e"),
60− key.WithHelp("ctrl+e", "open editor"),
61− ),
62−}
63−
6457var DeleteKeyMaps = DeleteAttachmentKeyMaps{
6558 AttachmentDeleteMode: key.NewBinding(
6659 key.WithKeys("ctrl+r"),
@@ -109,7 +102,7 @@ func (m *editorCmp) openEditor() tea.Cmd {
109102 os.Remove(tmpfile.Name())
110103 attachments := m.attachments
111104 m.attachments = nil
112− return SendMsg{
105+ return chat.SendMsg{
113106 Text: string(content),
114107 Attachments: attachments,
115108 }
@@ -134,7 +127,7 @@ func (m *editorCmp) send() tea.Cmd {
134127 return nil
135128 }
136129 return tea.Batch(
137− util.CmdHandler(SendMsg{
130+ util.CmdHandler(chat.SendMsg{
138131 Text: value,
139132 Attachments: attachments,
140133 }),
@@ -143,16 +136,12 @@ func (m *editorCmp) send() tea.Cmd {
143136
144137func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
145138 var cmd tea.Cmd
139+ var cmds []tea.Cmd
146140 switch msg := msg.(type) {
147141 case dialog.ThemeChangedMsg:
148142 m.textarea = CreateTextArea(&m.textarea)
149− case dialog.CompletionSelectedMsg:
150− existingValue := m.textarea.Value()
151− modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
152−
153− m.textarea.SetValue(modifiedValue)
154− return m, nil
155− case SessionSelectedMsg:
143+ return m, cmd
144+ case chat.SessionSelectedMsg:
156145 if msg.ID != m.session.ID {
157146 m.session = msg
158147 }
@@ -163,7 +152,64 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
163152 return m, cmd
164153 }
165154 m.attachments = append(m.attachments, msg.Attachment)
155+ return m, nil
156+ case completions.CompletionsClosedMsg:
157+ m.isCompletionsOpen = false
158+ m.currentQuery = ""
159+ m.completionsStartIndex = 0
160+ case completions.SelectCompletionMsg:
161+ if !m.isCompletionsOpen {
162+ return m, nil
163+ }
164+ if item, ok := msg.Value.(FileCompletionItem); ok {
165+ // If the selected item is a file, insert its path into the textarea
166+ value := m.textarea.Value()
167+ value = value[:m.completionsStartIndex]
168+ if len(value) > 0 && value[len(value)-1] != ' ' {
169+ value += " "
170+ }
171+ value += item.Path
172+ m.textarea.SetValue(value)
173+ m.isCompletionsOpen = false
174+ m.currentQuery = ""
175+ m.completionsStartIndex = 0
176+ return m, nil
177+ }
166178 case tea.KeyPressMsg:
179+ switch {
180+ // Completions
181+ case msg.String() == "/" && !m.isCompletionsOpen:
182+ m.isCompletionsOpen = true
183+ m.currentQuery = ""
184+ cmds = append(cmds, m.startCompletions)
185+ m.completionsStartIndex = len(m.textarea.Value())
186+ case msg.String() == "space" && m.isCompletionsOpen:
187+ m.isCompletionsOpen = false
188+ m.currentQuery = ""
189+ m.completionsStartIndex = 0
190+ cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
191+ case m.isCompletionsOpen && m.textarea.Cursor().X <= m.completionsStartIndex:
192+ cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
193+ case msg.String() == "backspace" && m.isCompletionsOpen:
194+ if len(m.currentQuery) > 0 {
195+ m.currentQuery = m.currentQuery[:len(m.currentQuery)-1]
196+ cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{
197+ Query: m.currentQuery,
198+ }))
199+ } else {
200+ m.isCompletionsOpen = false
201+ m.currentQuery = ""
202+ m.completionsStartIndex = 0
203+ cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
204+ }
205+ default:
206+ if m.isCompletionsOpen {
207+ m.currentQuery += msg.String()
208+ cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{
209+ Query: m.currentQuery,
210+ }))
211+ }
212+ }
167213 if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
168214 m.deleteMode = true
169215 return m, nil
@@ -186,7 +232,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
186232 return m, nil
187233 }
188234 }
189− if key.Matches(msg, editorMaps.OpenEditor) {
235+ if key.Matches(msg, m.keyMap.OpenEditor) {
190236 if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
191237 return m, util.ReportWarn("Agent is working, please wait...")
192238 }
@@ -197,7 +243,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
197243 return m, nil
198244 }
199245 // Hanlde Enter key
200− if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
246+ if m.textarea.Focused() && key.Matches(msg, m.keyMap.Send) {
201247 value := m.textarea.Value()
202248 if len(value) > 0 && value[len(value)-1] == '\\' {
203249 // 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) {
210256 }
211257 }
212258 m.textarea, cmd = m.textarea.Update(msg)
213− return m, cmd
259+ cmds = append(cmds, cmd)
260+ return m, tea.Batch(cmds...)
214261}
215262
216263func (m *editorCmp) View() tea.View {
@@ -223,8 +270,8 @@ func (m *editorCmp) View() tea.View {
223270 Foreground(t.Primary())
224271
225272 cursor := m.textarea.Cursor()
226− cursor.X = m.textarea.Cursor().X + m.x + 2
227− cursor.Y = m.textarea.Cursor().Y + m.y + 1
273+ cursor.X = cursor.X + m.x + 2
274+ cursor.Y = cursor.Y + m.y + 1
228275 if len(m.attachments) == 0 {
229276 view := tea.NewView(lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View()))
230277 view.SetCursor(cursor)
@@ -278,7 +325,7 @@ func (m *editorCmp) attachmentsContent() string {
278325
279326func (m *editorCmp) BindingKeys() []key.Binding {
280327 bindings := []key.Binding{}
281− bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
328+ bindings = append(bindings, layout.KeyMapToSlice(m.keyMap)...)
282329 bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
283330 return bindings
284331}
@@ -289,6 +336,28 @@ func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
289336 return nil
290337}
291338
339+func (m *editorCmp) startCompletions() tea.Msg {
340+ files, _, _ := fileutil.ListDirectory(".", []string{}, 0)
341+ completionItems := make([]completions.Completion, 0, len(files))
342+ for _, file := range files {
343+ file = strings.TrimPrefix(file, "./")
344+ completionItems = append(completionItems, completions.Completion{
345+ Title: file,
346+ Value: FileCompletionItem{
347+ Path: file,
348+ },
349+ })
350+ }
351+
352+ x := m.textarea.Cursor().X + m.x + 1
353+ y := m.textarea.Cursor().Y + m.y + 1
354+ return completions.OpenCompletionsMsg{
355+ Completions: completionItems,
356+ X: x,
357+ Y: y,
358+ }
359+}
360+
292361func CreateTextArea(existing *textarea.Model) textarea.Model {
293362 t := theme.CurrentTheme()
294363 bgColor := t.Background()
@@ -333,5 +402,6 @@ func NewEditorCmp(app *app.App) util.Model {
333402 return &editorCmp{
334403 app: app,
335404 textarea: ta,
405+ keyMap: DefaultEditorKeyMap(),
336406 }
337407}
internal/tui/components/chat/editor/keys.gonew file+59
@@ -0,0 +1,59 @@
1+package editor
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ "github.com/opencode-ai/opencode/internal/tui/layout"
6+)
7+
8+type EditorKeyMap struct {
9+ Send key.Binding
10+ OpenEditor key.Binding
11+}
12+
13+func DefaultEditorKeyMap() EditorKeyMap {
14+ return EditorKeyMap{
15+ Send: key.NewBinding(
16+ key.WithKeys("enter"),
17+ key.WithHelp("enter", "send"),
18+ ),
19+ OpenEditor: key.NewBinding(
20+ key.WithKeys("ctrl+e"),
21+ key.WithHelp("ctrl+e", "open editor"),
22+ ),
23+ }
24+}
25+
26+// FullHelp implements help.KeyMap.
27+func (k EditorKeyMap) FullHelp() [][]key.Binding {
28+ m := [][]key.Binding{}
29+ slice := layout.KeyMapToSlice(k)
30+ for i := 0; i < len(slice); i += 4 {
31+ end := min(i+4, len(slice))
32+ m = append(m, slice[i:end])
33+ }
34+ return m
35+}
36+
37+// ShortHelp implements help.KeyMap.
38+func (k EditorKeyMap) ShortHelp() []key.Binding {
39+ return []key.Binding{
40+ k.Send,
41+ k.OpenEditor,
42+ }
43+}
44+
45+// TODO: update this to use the new keymap concepts
46+var AttachmentsKeyMaps = DeleteAttachmentKeyMaps{
47+ AttachmentDeleteMode: key.NewBinding(
48+ key.WithKeys("ctrl+r"),
49+ key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
50+ ),
51+ Escape: key.NewBinding(
52+ key.WithKeys("esc"),
53+ key.WithHelp("esc", "cancel delete mode"),
54+ ),
55+ DeleteAllAttachments: key.NewBinding(
56+ key.WithKeys("r"),
57+ key.WithHelp("ctrl+r+r", "delete all attachments"),
58+ ),
59+}
internal/tui/components/completions/completions.gonew file+195
@@ -0,0 +1,195 @@
1+package completions
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ tea "github.com/charmbracelet/bubbletea/v2"
6+ "github.com/charmbracelet/lipgloss/v2"
7+ "github.com/opencode-ai/opencode/internal/tui/components/core/list"
8+ "github.com/opencode-ai/opencode/internal/tui/styles"
9+ "github.com/opencode-ai/opencode/internal/tui/theme"
10+ "github.com/opencode-ai/opencode/internal/tui/util"
11+)
12+
13+type Completion struct {
14+ Title string // The title of the completion item
15+ Value any // The value of the completion item
16+}
17+
18+type OpenCompletionsMsg struct {
19+ Completions []Completion
20+ X int // X position for the completions popup
21+ Y int // Y position for the completions popup
22+}
23+
24+type FilterCompletionsMsg struct {
25+ Query string // The query to filter completions
26+}
27+
28+type CompletionsClosedMsg struct{}
29+
30+type CloseCompletionsMsg struct{}
31+
32+type SelectCompletionMsg struct {
33+ Value any // The value of the selected completion item
34+}
35+
36+type Completions interface {
37+ util.Model
38+ Open() bool
39+ Query() string // Returns the current filter query
40+ KeyMap() KeyMap
41+ Position() (int, int) // Returns the X and Y position of the completions popup
42+}
43+
44+type completionsCmp struct {
45+ width int
46+ height int // Height of the completions component`
47+ x int // X position for the completions popup\
48+ y int // Y position for the completions popup
49+ open bool // Indicates if the completions are open
50+ keyMap KeyMap
51+
52+ list list.ListModel
53+ query string // The current filter query
54+}
55+
56+func New() Completions {
57+ completionsKeyMap := DefaultKeyMap()
58+ keyMap := list.DefaultKeyMap()
59+ keyMap.Up.SetEnabled(false)
60+ keyMap.Down.SetEnabled(false)
61+ keyMap.NDown.SetEnabled(false)
62+ keyMap.NUp.SetEnabled(false)
63+ keyMap.HalfPageDown.SetEnabled(false)
64+ keyMap.HalfPageUp.SetEnabled(false)
65+ keyMap.Home.SetEnabled(false)
66+ keyMap.End.SetEnabled(false)
67+ keyMap.UpOneItem = completionsKeyMap.Up
68+ keyMap.DownOneItem = completionsKeyMap.Down
69+
70+ l := list.New(
71+ list.WithReverse(true),
72+ list.WithKeyMap(keyMap),
73+ list.WithHideFilterInput(true),
74+ )
75+ return &completionsCmp{
76+ width: 30,
77+ height: 10,
78+ list: l,
79+ query: "",
80+ keyMap: completionsKeyMap,
81+ }
82+}
83+
84+// Init implements Completions.
85+func (c *completionsCmp) Init() tea.Cmd {
86+ return tea.Sequence(
87+ c.list.Init(),
88+ c.list.SetSize(c.width, c.height),
89+ )
90+}
91+
92+// Update implements Completions.
93+func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
94+ switch msg := msg.(type) {
95+ case tea.KeyPressMsg:
96+ switch {
97+ case key.Matches(msg, c.keyMap.Up):
98+ u, cmd := c.list.Update(msg)
99+ c.list = u.(list.ListModel)
100+ return c, cmd
101+
102+ case key.Matches(msg, c.keyMap.Down):
103+ d, cmd := c.list.Update(msg)
104+ c.list = d.(list.ListModel)
105+ return c, cmd
106+ case key.Matches(msg, c.keyMap.Select):
107+ selectedItemInx := c.list.SelectedIndex()
108+ if selectedItemInx == list.NoSelection {
109+ return c, nil // No item selected, do nothing
110+ }
111+ items := c.list.Items()
112+ selectedItem := items[selectedItemInx].(CompletionItem).Value()
113+ c.open = false // Close completions after selection
114+ return c, util.CmdHandler(SelectCompletionMsg{
115+ Value: selectedItem,
116+ })
117+ case key.Matches(msg, c.keyMap.Cancel):
118+ if c.open {
119+ c.open = false
120+ return c, util.CmdHandler(CompletionsClosedMsg{})
121+ }
122+ }
123+ case CloseCompletionsMsg:
124+ c.open = false
125+ c.query = ""
126+ return c, tea.Batch(
127+ c.list.SetItems([]util.Model{}),
128+ util.CmdHandler(CompletionsClosedMsg{}),
129+ )
130+ case OpenCompletionsMsg:
131+ c.open = true
132+ c.query = ""
133+ c.x = msg.X
134+ c.y = msg.Y
135+ items := []util.Model{}
136+ for _, completion := range msg.Completions {
137+ item := NewCompletionItem(completion.Title, completion.Value)
138+ items = append(items, item)
139+ }
140+ c.height = max(min(10, len(items)), 1) // Ensure at least 1 item height
141+ cmds := []tea.Cmd{
142+ c.list.SetSize(c.width, c.height),
143+ c.list.SetItems(items),
144+ }
145+ return c, tea.Batch(cmds...)
146+ case FilterCompletionsMsg:
147+ c.query = msg.Query
148+ if !c.open {
149+ return c, nil // If completions are not open, do nothing
150+ }
151+ cmd := c.list.Filter(msg.Query)
152+ c.height = max(min(10, len(c.list.Items())), 1)
153+ return c, tea.Batch(
154+ cmd,
155+ c.list.SetSize(c.width, c.height),
156+ )
157+ }
158+ return c, nil
159+}
160+
161+// View implements Completions.
162+func (c *completionsCmp) View() tea.View {
163+ if len(c.list.Items()) == 0 {
164+ return tea.NewView(c.style().Render("No completions found"))
165+ }
166+
167+ view := tea.NewView(
168+ c.style().Render(c.list.View().String()),
169+ )
170+ return view
171+}
172+
173+func (c *completionsCmp) style() lipgloss.Style {
174+ t := theme.CurrentTheme()
175+ return styles.BaseStyle().
176+ Width(c.width).
177+ Height(c.height).
178+ Background(t.BackgroundSecondary())
179+}
180+
181+func (c *completionsCmp) Open() bool {
182+ return c.open
183+}
184+
185+func (c *completionsCmp) Query() string {
186+ return c.query
187+}
188+
189+func (c *completionsCmp) KeyMap() KeyMap {
190+ return c.keyMap
191+}
192+
193+func (c *completionsCmp) Position() (int, int) {
194+ return c.x, c.y - c.height
195+}
internal/tui/components/completions/item.gonew file+247
@@ -0,0 +1,247 @@
1+package completions
2+
3+import (
4+ tea "github.com/charmbracelet/bubbletea/v2"
5+ "github.com/charmbracelet/lipgloss/v2"
6+ "github.com/charmbracelet/x/ansi"
7+ "github.com/opencode-ai/opencode/internal/tui/components/core/list"
8+ "github.com/opencode-ai/opencode/internal/tui/layout"
9+ "github.com/opencode-ai/opencode/internal/tui/styles"
10+ "github.com/opencode-ai/opencode/internal/tui/theme"
11+ "github.com/opencode-ai/opencode/internal/tui/util"
12+ "github.com/rivo/uniseg"
13+)
14+
15+type CompletionItem interface {
16+ util.Model
17+ layout.Focusable
18+ layout.Sizeable
19+ list.HasMatchIndexes
20+ list.HasFilterValue
21+ Value() any
22+}
23+
24+type completionItemCmp struct {
25+ width int
26+ text string
27+ value any
28+ focus bool
29+ matchIndexes []int
30+}
31+
32+func NewCompletionItem(text string, value any, matchIndexes ...int) CompletionItem {
33+ return &completionItemCmp{
34+ text: text,
35+ value: value,
36+ matchIndexes: matchIndexes,
37+ }
38+}
39+
40+// Init implements CommandItem.
41+func (c *completionItemCmp) Init() tea.Cmd {
42+ return nil
43+}
44+
45+// Update implements CommandItem.
46+func (c *completionItemCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
47+ return c, nil
48+}
49+
50+// View implements CommandItem.
51+func (c *completionItemCmp) View() tea.View {
52+ t := theme.CurrentTheme()
53+
54+ baseStyle := styles.BaseStyle().Background(t.BackgroundSecondary())
55+ titleStyle := baseStyle.Padding(0, 1).Width(c.width).Foreground(t.Text())
56+ titleMatchStyle := baseStyle.Foreground(t.Text()).Underline(true)
57+
58+ if c.focus {
59+ titleStyle = titleStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true)
60+ titleMatchStyle = titleMatchStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true)
61+ }
62+
63+ var truncatedTitle string
64+ var adjustedMatchIndexes []int
65+
66+ availableWidth := c.width - 2 // Account for padding
67+ if len(c.matchIndexes) > 0 && len(c.text) > availableWidth {
68+ // Smart truncation: ensure the last matching part is visible
69+ truncatedTitle, adjustedMatchIndexes = c.smartTruncate(c.text, availableWidth, c.matchIndexes)
70+ } else {
71+ // No matches, use regular truncation
72+ truncatedTitle = ansi.Truncate(c.text, availableWidth, "…")
73+ adjustedMatchIndexes = c.matchIndexes
74+ }
75+
76+ text := titleStyle.Render(truncatedTitle)
77+ if len(adjustedMatchIndexes) > 0 {
78+ var ranges []lipgloss.Range
79+ for _, rng := range matchedRanges(adjustedMatchIndexes) {
80+ // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
81+ // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
82+ // so we need to adjust it here:
83+ start, stop := bytePosToVisibleCharPos(text, rng)
84+ ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
85+ }
86+ text = lipgloss.StyleRanges(text, ranges...)
87+ }
88+ return tea.NewView(text)
89+}
90+
91+// Blur implements CommandItem.
92+func (c *completionItemCmp) Blur() tea.Cmd {
93+ c.focus = false
94+ return nil
95+}
96+
97+// Focus implements CommandItem.
98+func (c *completionItemCmp) Focus() tea.Cmd {
99+ c.focus = true
100+ return nil
101+}
102+
103+// GetSize implements CommandItem.
104+func (c *completionItemCmp) GetSize() (int, int) {
105+ return c.width, 1
106+}
107+
108+// IsFocused implements CommandItem.
109+func (c *completionItemCmp) IsFocused() bool {
110+ return c.focus
111+}
112+
113+// SetSize implements CommandItem.
114+func (c *completionItemCmp) SetSize(width int, height int) tea.Cmd {
115+ c.width = width
116+ return nil
117+}
118+
119+func (c *completionItemCmp) MatchIndexes(indexes []int) {
120+ c.matchIndexes = indexes
121+ for i := range c.matchIndexes {
122+ c.matchIndexes[i] += 1 // Adjust for the padding we add in View
123+ }
124+}
125+
126+func (c *completionItemCmp) FilterValue() string {
127+ return c.text
128+}
129+
130+func (c *completionItemCmp) Value() any {
131+ return c.value
132+}
133+
134+// smartTruncate implements fzf-style truncation that ensures the last matching part is visible
135+func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes []int) (string, []int) {
136+ if width <= 0 {
137+ return "", []int{}
138+ }
139+
140+ textLen := ansi.StringWidth(text)
141+ if textLen <= width {
142+ return text, matchIndexes
143+ }
144+
145+ if len(matchIndexes) == 0 {
146+ return ansi.Truncate(text, width, "…"), []int{}
147+ }
148+
149+ // Find the last match position
150+ lastMatchPos := matchIndexes[len(matchIndexes)-1]
151+
152+ // Convert byte position to visual width position
153+ lastMatchVisualPos := 0
154+ bytePos := 0
155+ gr := uniseg.NewGraphemes(text)
156+ for bytePos < lastMatchPos && gr.Next() {
157+ bytePos += len(gr.Str())
158+ lastMatchVisualPos += max(1, gr.Width())
159+ }
160+
161+ // Calculate how much space we need for the ellipsis
162+ ellipsisWidth := 1 // "…" character width
163+ availableWidth := width - ellipsisWidth
164+
165+ // If the last match is within the available width, truncate from the end
166+ if lastMatchVisualPos < availableWidth {
167+ return ansi.Truncate(text, width, "…"), matchIndexes
168+ }
169+
170+ // Calculate the start position to ensure the last match is visible
171+ // We want to show some context before the last match if possible
172+ startVisualPos := max(0, lastMatchVisualPos-availableWidth+1)
173+
174+ // Convert visual position back to byte position
175+ startBytePos := 0
176+ currentVisualPos := 0
177+ gr = uniseg.NewGraphemes(text)
178+ for currentVisualPos < startVisualPos && gr.Next() {
179+ startBytePos += len(gr.Str())
180+ currentVisualPos += max(1, gr.Width())
181+ }
182+
183+ // Extract the substring starting from startBytePos
184+ truncatedText := text[startBytePos:]
185+
186+ // Truncate to fit width with ellipsis
187+ truncatedText = ansi.Truncate(truncatedText, availableWidth, "")
188+ truncatedText = "…" + truncatedText
189+
190+ // Adjust match indexes for the new truncated string
191+ adjustedIndexes := []int{}
192+ for _, idx := range matchIndexes {
193+ if idx >= startBytePos {
194+ newIdx := idx - startBytePos + 1 //
195+ // Check if this match is still within the truncated string
196+ if newIdx < len(truncatedText) {
197+ adjustedIndexes = append(adjustedIndexes, newIdx)
198+ }
199+ }
200+ }
201+
202+ return truncatedText, adjustedIndexes
203+}
204+
205+func matchedRanges(in []int) [][2]int {
206+ if len(in) == 0 {
207+ return [][2]int{}
208+ }
209+ current := [2]int{in[0], in[0]}
210+ if len(in) == 1 {
211+ return [][2]int{current}
212+ }
213+ var out [][2]int
214+ for i := 1; i < len(in); i++ {
215+ if in[i] == current[1]+1 {
216+ current[1] = in[i]
217+ } else {
218+ out = append(out, current)
219+ current = [2]int{in[i], in[i]}
220+ }
221+ }
222+ out = append(out, current)
223+ return out
224+}
225+
226+func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
227+ bytePos, byteStart, byteStop := 0, rng[0], rng[1]
228+ pos, start, stop := 0, 0, 0
229+ gr := uniseg.NewGraphemes(str)
230+ for byteStart > bytePos {
231+ if !gr.Next() {
232+ break
233+ }
234+ bytePos += len(gr.Str())
235+ pos += max(1, gr.Width())
236+ }
237+ start = pos
238+ for byteStop > bytePos {
239+ if !gr.Next() {
240+ break
241+ }
242+ bytePos += len(gr.Str())
243+ pos += max(1, gr.Width())
244+ }
245+ stop = pos
246+ return start, stop
247+}
internal/tui/components/completions/keys.gonew file+53
@@ -0,0 +1,53 @@
1+package completions
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ "github.com/opencode-ai/opencode/internal/tui/layout"
6+)
7+
8+type KeyMap struct {
9+ Down,
10+ Up,
11+ Select,
12+ Cancel key.Binding
13+}
14+
15+func DefaultKeyMap() KeyMap {
16+ return KeyMap{
17+ Down: key.NewBinding(
18+ key.WithKeys("down"),
19+ key.WithHelp("down", "move down"),
20+ ),
21+ Up: key.NewBinding(
22+ key.WithKeys("up"),
23+ key.WithHelp("up", "move up"),
24+ ),
25+ Select: key.NewBinding(
26+ key.WithKeys("enter"),
27+ key.WithHelp("enter", "select"),
28+ ),
29+ Cancel: key.NewBinding(
30+ key.WithKeys("esc"),
31+ key.WithHelp("esc", "cancel"),
32+ ),
33+ }
34+}
35+
36+// FullHelp implements help.KeyMap.
37+func (k KeyMap) FullHelp() [][]key.Binding {
38+ m := [][]key.Binding{}
39+ slice := layout.KeyMapToSlice(k)
40+ for i := 0; i < len(slice); i += 4 {
41+ end := min(i+4, len(slice))
42+ m = append(m, slice[i:end])
43+ }
44+ return m
45+}
46+
47+// ShortHelp implements help.KeyMap.
48+func (k KeyMap) ShortHelp() []key.Binding {
49+ return []key.Binding{
50+ k.Up,
51+ k.Down,
52+ }
53+}
internal/tui/components/core/list/list.go+23−15
@@ -39,6 +39,7 @@ type ListModel interface {
3939 ResetView() // Clear rendering cache and reset scroll position
4040 Items() []util.Model // Get all items in the list
4141 SelectedIndex() int // Get the index of the currently selected item
42+ Filter(string) tea.Cmd // Filter items based on a search term
4243}
4344
4445// HasAnim interface identifies items that support animation.
@@ -50,13 +51,11 @@ type HasAnim interface {
5051
5152// HasFilterValue interface allows items to provide a filter value for searching.
5253type HasFilterValue interface {
53− util.Model
5454 FilterValue() string // Returns a string value used for filtering/searching
5555}
5656
5757// HasMatchIndexes interface allows items to set matched character indexes.
5858type HasMatchIndexes interface {
59− util.Model
6059 MatchIndexes([]int) // Sets the indexes of matched characters in the item's content
6160}
6261
@@ -134,10 +133,11 @@ type model struct {
134133 gapSize int // Number of empty lines between items
135134 padding []int // Padding around the list content
136135
137− filterable bool // Whether items can be filtered
138− filteredItems []util.Model // Filtered items based on current search
139− input textinput.Model // Input field for filtering items
140− currentSearch string // Current search term for filtering
136+ filterable bool // Whether items can be filtered
137+ filteredItems []util.Model // Filtered items based on current search
138+ input textinput.Model // Input field for filtering items
139+ hideFilterInput bool // Whether to hide the filter input field
140+ currentSearch string // Current search term for filtering
141141}
142142
143143// listOptions is a function type for configuring list options.
@@ -188,6 +188,13 @@ func WithFilterable(filterable bool) listOptions {
188188 }
189189}
190190
191+// WithHideFilterInput hides the filter input field.
192+func WithHideFilterInput(hide bool) listOptions {
193+ return func(m *model) {
194+ m.hideFilterInput = hide
195+ }
196+}
197+
191198// New creates a new list model with the specified options.
192199// The list starts with no items selected and requires SetItems to be called
193200// or items to be provided via WithItems option.
@@ -206,7 +213,7 @@ func New(opts ...listOptions) ListModel {
206213 opt(m)
207214 }
208215
209− if m.filterable {
216+ if m.filterable && !m.hideFilterInput {
210217 ti := textinput.New()
211218 ti.Placeholder = "Type to filter..."
212219 ti.SetVirtualCursor(false)
@@ -259,7 +266,7 @@ func (m *model) View() tea.View {
259266 Height(m.viewState.height).
260267 Render(m.viewState.content)
261268
262− if m.filterable {
269+ if m.filterable && !m.hideFilterInput {
263270 content = lipgloss.JoinVertical(
264271 lipgloss.Left,
265272 m.inputStyle().Render(m.input.View()),
@@ -267,7 +274,7 @@ func (m *model) View() tea.View {
267274 )
268275 }
269276 view := tea.NewView(content)
270− if m.filterable {
277+ if m.filterable && !m.hideFilterInput {
271278 view.SetCursor(m.input.Cursor())
272279 }
273280 return view
@@ -294,15 +301,15 @@ func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
294301 case key.Matches(msg, m.keyMap.End):
295302 return m, m.goToBottom()
296303 default:
297− if !m.filterable {
298− return m, nil // Ignore other keys if not filterable
304+ if !m.filterable || m.hideFilterInput {
305+ return m, nil // Ignore other keys if not filterable or input is hidden
299306 }
300307 var cmds []tea.Cmd
301308 u, cmd := m.input.Update(msg)
302309 m.input = u
303310 cmds = append(cmds, cmd)
304311 if m.currentSearch != m.input.Value() {
305− cmd = m.filter(m.input.Value())
312+ cmd = m.Filter(m.input.Value())
306313 cmds = append(cmds, cmd)
307314 }
308315 m.currentSearch = m.input.Value()
@@ -923,7 +930,7 @@ func (m *model) GetSize() (int, int) {
923930// SetSize updates the list dimensions and triggers a complete re-render.
924931// Also updates the size of all items that support sizing.
925932func (m *model) SetSize(width int, height int) tea.Cmd {
926− if m.filterable {
933+ if m.filterable && !m.hideFilterInput {
927934 height -= 2 // adjust for input field height and border
928935 }
929936
@@ -936,7 +943,7 @@ func (m *model) SetSize(width int, height int) tea.Cmd {
936943 }
937944 m.viewState.width = width
938945 m.ResetView()
939− if m.filterable {
946+ if m.filterable && !m.hideFilterInput {
940947 m.input.SetWidth(m.getItemWidth() - 3)
941948 }
942949 return m.setAllItemsSize()
@@ -1152,7 +1159,7 @@ func (m *model) flattenSections(sections []section) []util.Model {
11521159 return result
11531160}
11541161
1155−func (m *model) filter(search string) tea.Cmd {
1162+func (m *model) Filter(search string) tea.Cmd {
11561163 var cmds []tea.Cmd
11571164 search = strings.TrimSpace(search)
11581165 search = strings.ToLower(search)
@@ -1189,6 +1196,7 @@ func (m *model) filter(search string) tea.Cmd {
11891196 // Set initial selection
11901197 if len(m.filteredItems) > 0 {
11911198 if m.viewState.reverse {
1199+ slices.Reverse(m.filteredItems)
11921200 m.selectionState.selectedIndex = m.findLastSelectableItem()
11931201 } else {
11941202 m.selectionState.selectedIndex = m.findFirstSelectableItem()
internal/tui/components/dialog/arguments.godeleted−252
@@ -1,252 +0,0 @@
1−package dialog
2−
3−import (
4− "fmt"
5−
6− "github.com/charmbracelet/bubbles/v2/key"
7− "github.com/charmbracelet/bubbles/v2/textinput"
8− tea "github.com/charmbracelet/bubbletea/v2"
9− "github.com/charmbracelet/lipgloss/v2"
10−
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 argumentsDialogKeyMap struct {
17− Enter key.Binding
18− Escape key.Binding
19−}
20−
21−// ShortHelp implements key.Map.
22−func (k argumentsDialogKeyMap) ShortHelp() []key.Binding {
23− return []key.Binding{
24− key.NewBinding(
25− key.WithKeys("enter"),
26− key.WithHelp("enter", "confirm"),
27− ),
28− key.NewBinding(
29− key.WithKeys("esc"),
30− key.WithHelp("esc", "cancel"),
31− ),
32− }
33−}
34−
35−// FullHelp implements key.Map.
36−func (k argumentsDialogKeyMap) FullHelp() [][]key.Binding {
37− return [][]key.Binding{k.ShortHelp()}
38−}
39−
40−// ShowMultiArgumentsDialogMsg is a message that is sent to show the multi-arguments dialog.
41−type ShowMultiArgumentsDialogMsg struct {
42− CommandID string
43− Content string
44− ArgNames []string
45−}
46−
47−// CloseMultiArgumentsDialogMsg is a message that is sent when the multi-arguments dialog is closed.
48−type CloseMultiArgumentsDialogMsg struct {
49− Submit bool
50− CommandID string
51− Content string
52− Args map[string]string
53−}
54−
55−// MultiArgumentsDialogCmp is a component that asks the user for multiple command arguments.
56−type MultiArgumentsDialogCmp struct {
57− width, height int
58− inputs []textinput.Model
59− focusIndex int
60− keys argumentsDialogKeyMap
61− commandID string
62− content string
63− argNames []string
64−}
65−
66−// NewMultiArgumentsDialogCmp creates a new MultiArgumentsDialogCmp.
67−func NewMultiArgumentsDialogCmp(commandID, content string, argNames []string) MultiArgumentsDialogCmp {
68− t := theme.CurrentTheme()
69− inputs := make([]textinput.Model, len(argNames))
70−
71− for i, name := range argNames {
72− ti := textinput.New()
73− ti.Placeholder = fmt.Sprintf("Enter value for %s...", name)
74− ti.SetWidth(40)
75− ti.Prompt = ""
76− styles := ti.Styles()
77− styles.Focused.Placeholder = styles.Focused.Placeholder.Background(t.Background())
78− styles.Blurred.Placeholder = styles.Blurred.Placeholder.Background(t.Background())
79− styles.Focused.Suggestion = styles.Focused.Suggestion.Background(t.Background()).Foreground(t.Primary())
80− styles.Blurred.Suggestion = styles.Blurred.Suggestion.Background(t.Background())
81− styles.Focused.Text = styles.Focused.Text.Background(t.Background()).Foreground(t.Primary())
82− styles.Blurred.Text = styles.Blurred.Text.Background(t.Background())
83−
84− // Only focus the first input initially
85− if i == 0 {
86− ti.Focus()
87− } else {
88− ti.Blur()
89− }
90−
91− inputs[i] = ti
92− }
93−
94− return MultiArgumentsDialogCmp{
95− inputs: inputs,
96− keys: argumentsDialogKeyMap{},
97− commandID: commandID,
98− content: content,
99− argNames: argNames,
100− focusIndex: 0,
101− }
102−}
103−
104−// Init implements tea.Model.
105−func (m MultiArgumentsDialogCmp) Init() tea.Cmd {
106− // Make sure only the first input is focused
107− for i := range m.inputs {
108− if i == 0 {
109− m.inputs[i].Focus()
110− } else {
111− m.inputs[i].Blur()
112− }
113− }
114−
115− return textinput.Blink
116−}
117−
118−// Update implements tea.Model.
119−func (m MultiArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
120− var cmds []tea.Cmd
121− switch msg := msg.(type) {
122− case tea.KeyPressMsg:
123− switch {
124− case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
125− return m, util.CmdHandler(CloseMultiArgumentsDialogMsg{
126− Submit: false,
127− CommandID: m.commandID,
128− Content: m.content,
129− Args: nil,
130− })
131− case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
132− // If we're on the last input, submit the form
133− if m.focusIndex == len(m.inputs)-1 {
134− args := make(map[string]string)
135− for i, name := range m.argNames {
136− args[name] = m.inputs[i].Value()
137− }
138− return m, util.CmdHandler(CloseMultiArgumentsDialogMsg{
139− Submit: true,
140− CommandID: m.commandID,
141− Content: m.content,
142− Args: args,
143− })
144− }
145− // Otherwise, move to the next input
146− m.inputs[m.focusIndex].Blur()
147− m.focusIndex++
148− m.inputs[m.focusIndex].Focus()
149− case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
150− // Move to the next input
151− m.inputs[m.focusIndex].Blur()
152− m.focusIndex = (m.focusIndex + 1) % len(m.inputs)
153− m.inputs[m.focusIndex].Focus()
154− case key.Matches(msg, key.NewBinding(key.WithKeys("shift+tab"))):
155− // Move to the previous input
156− m.inputs[m.focusIndex].Blur()
157− m.focusIndex = (m.focusIndex - 1 + len(m.inputs)) % len(m.inputs)
158− m.inputs[m.focusIndex].Focus()
159− }
160− case tea.WindowSizeMsg:
161− m.width = msg.Width
162− m.height = msg.Height
163− }
164−
165− // Update the focused input
166− var cmd tea.Cmd
167− m.inputs[m.focusIndex], cmd = m.inputs[m.focusIndex].Update(msg)
168− cmds = append(cmds, cmd)
169−
170− return m, tea.Batch(cmds...)
171−}
172−
173−// View implements tea.Model.
174−func (m MultiArgumentsDialogCmp) View() string {
175− t := theme.CurrentTheme()
176− baseStyle := styles.BaseStyle()
177−
178− // Calculate width needed for content
179− maxWidth := 60 // Width for explanation text
180−
181− title := lipgloss.NewStyle().
182− Foreground(t.Primary()).
183− Bold(true).
184− Width(maxWidth).
185− Padding(0, 1).
186− Background(t.Background()).
187− Render("Command Arguments")
188−
189− explanation := lipgloss.NewStyle().
190− Foreground(t.Text()).
191− Width(maxWidth).
192− Padding(0, 1).
193− Background(t.Background()).
194− Render("This command requires multiple arguments. Please enter values for each:")
195−
196− // Create input fields for each argument
197− inputFields := make([]string, len(m.inputs))
198− for i, input := range m.inputs {
199− // Highlight the label of the focused input
200− labelStyle := lipgloss.NewStyle().
201− Width(maxWidth).
202− Padding(1, 1, 0, 1).
203− Background(t.Background())
204−
205− if i == m.focusIndex {
206− labelStyle = labelStyle.Foreground(t.Primary()).Bold(true)
207− } else {
208− labelStyle = labelStyle.Foreground(t.TextMuted())
209− }
210−
211− label := labelStyle.Render(m.argNames[i] + ":")
212−
213− field := lipgloss.NewStyle().
214− Foreground(t.Text()).
215− Width(maxWidth).
216− Padding(0, 1).
217− Background(t.Background()).
218− Render(input.View())
219−
220− inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field)
221− }
222−
223− maxWidth = min(maxWidth, m.width-10)
224−
225− // Join all elements vertically
226− elements := []string{title, explanation}
227− elements = append(elements, inputFields...)
228−
229− content := lipgloss.JoinVertical(
230− lipgloss.Left,
231− elements...,
232− )
233−
234− return baseStyle.Padding(1, 2).
235− Border(lipgloss.RoundedBorder()).
236− BorderBackground(t.Background()).
237− BorderForeground(t.TextMuted()).
238− Background(t.Background()).
239− Width(lipgloss.Width(content) + 4).
240− Render(content)
241−}
242−
243−// SetSize sets the size of the component.
244−func (m *MultiArgumentsDialogCmp) SetSize(width, height int) {
245− m.width = width
246− m.height = height
247−}
248−
249−// Bindings implements layout.Bindings.
250−func (m MultiArgumentsDialogCmp) Bindings() []key.Binding {
251− return m.keys.ShortHelp()
252−}
internal/tui/components/dialog/commands.godeleted−182
@@ -1,182 +0,0 @@
1−package dialog
2−
3−import (
4− "github.com/charmbracelet/bubbles/v2/key"
5− tea "github.com/charmbracelet/bubbletea/v2"
6− "github.com/charmbracelet/lipgloss/v2"
7− utilComponents "github.com/opencode-ai/opencode/internal/tui/components/util"
8− "github.com/opencode-ai/opencode/internal/tui/layout"
9− "github.com/opencode-ai/opencode/internal/tui/styles"
10− "github.com/opencode-ai/opencode/internal/tui/theme"
11− "github.com/opencode-ai/opencode/internal/tui/util"
12−)
13−
14−// Command represents a command that can be executed
15−type Command struct {
16− ID string
17− Title string
18− Description string
19− Handler func(cmd Command) tea.Cmd
20−}
21−
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−
49−// CommandSelectedMsg is sent when a command is selected
50−type CommandSelectedMsg struct {
51− Command Command
52−}
53−
54−// CloseCommandDialogMsg is sent when the command dialog is closed
55−type CloseCommandDialogMsg struct{}
56−
57−// CommandDialog interface for the command selection dialog
58−type CommandDialog interface {
59− util.Model
60− layout.Bindings
61− SetCommands(commands []Command)
62−}
63−
64−type commandDialogCmp struct {
65− listView utilComponents.SimpleList[Command]
66− width int
67− height int
68−}
69−
70−type commandKeyMap struct {
71− Enter key.Binding
72− Escape key.Binding
73−}
74−
75−var commandKeys = commandKeyMap{
76− Enter: key.NewBinding(
77− key.WithKeys("enter"),
78− key.WithHelp("enter", "select command"),
79− ),
80− Escape: key.NewBinding(
81− key.WithKeys("esc"),
82− key.WithHelp("esc", "close"),
83− ),
84−}
85−
86−func (c *commandDialogCmp) Init() tea.Cmd {
87− return c.listView.Init()
88−}
89−
90−func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
91− var cmds []tea.Cmd
92− switch msg := msg.(type) {
93− case tea.KeyPressMsg:
94− switch {
95− case key.Matches(msg, commandKeys.Enter):
96− selectedItem, idx := c.listView.GetSelectedItem()
97− if idx != -1 {
98− return c, util.CmdHandler(CommandSelectedMsg{
99− Command: selectedItem,
100− })
101− }
102− case key.Matches(msg, commandKeys.Escape):
103− return c, util.CmdHandler(CloseCommandDialogMsg{})
104− }
105− case tea.WindowSizeMsg:
106− c.width = msg.Width
107− c.height = msg.Height
108− }
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...)
115−}
116−
117−func (c *commandDialogCmp) View() tea.View {
118− t := theme.CurrentTheme()
119− baseStyle := styles.BaseStyle()
120−
121− maxWidth := 40
122−
123− commands := c.listView.GetItems()
124−
125− for _, cmd := range commands {
126− if len(cmd.Title) > maxWidth-4 {
127− maxWidth = len(cmd.Title) + 4
128− }
129− if cmd.Description != "" {
130− if len(cmd.Description) > maxWidth-4 {
131− maxWidth = len(cmd.Description) + 4
132− }
133− }
134− }
135−
136− c.listView.SetMaxWidth(maxWidth)
137−
138− title := baseStyle.
139− Foreground(t.Primary()).
140− Bold(true).
141− Width(maxWidth).
142− Padding(0, 1).
143− Render("Commands")
144−
145− content := lipgloss.JoinVertical(
146− lipgloss.Left,
147− title,
148− baseStyle.Width(maxWidth).Render(""),
149− baseStyle.Width(maxWidth).Render(c.listView.View().String()),
150− baseStyle.Width(maxWidth).Render(""),
151− )
152−
153− return tea.NewView(
154− baseStyle.Padding(1, 2).
155− Border(lipgloss.RoundedBorder()).
156− BorderBackground(t.Background()).
157− BorderForeground(t.TextMuted()).
158− Width(lipgloss.Width(content) + 4).
159− Render(content),
160− )
161−}
162−
163−func (c *commandDialogCmp) BindingKeys() []key.Binding {
164− return layout.KeyMapToSlice(commandKeys)
165−}
166−
167−func (c *commandDialogCmp) SetCommands(commands []Command) {
168− c.listView.SetItems(commands)
169−}
170−
171−// NewCommandDialogCmp creates a new command selection dialog
172−func NewCommandDialogCmp() CommandDialog {
173− listView := utilComponents.NewSimpleList[Command](
174− []Command{},
175− 10,
176− "No commands available",
177− true,
178− )
179− return &commandDialogCmp{
180− listView: listView,
181− }
182−}
internal/tui/components/dialog/complete.godeleted−264
@@ -1,264 +0,0 @@
1−package dialog
2−
3−import (
4− "github.com/charmbracelet/bubbles/v2/key"
5− "github.com/charmbracelet/bubbles/v2/textarea"
6− tea "github.com/charmbracelet/bubbletea/v2"
7− "github.com/charmbracelet/lipgloss/v2"
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− util.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.KeyPressMsg:
140− if c.pseudoSearchTextArea.Focused() {
141− if !key.Matches(msg, completionDialogKeys.Complete) {
142− var cmd tea.Cmd
143− c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
144− cmds = append(cmds, cmd)
145−
146− var query string
147− query = c.pseudoSearchTextArea.Value()
148− if query != "" {
149− query = query[1:]
150− }
151−
152− if query != c.query {
153− logging.Info("Query", query)
154− items, err := c.completionProvider.GetChildEntries(query)
155− if err != nil {
156− logging.Error("Failed to get child entries", err)
157− }
158−
159− c.listView.SetItems(items)
160− c.query = query
161− }
162−
163− u, cmd := c.listView.Update(msg)
164− c.listView = u.(utilComponents.SimpleList[CompletionItemI])
165−
166− cmds = append(cmds, cmd)
167− }
168−
169− switch {
170− case key.Matches(msg, completionDialogKeys.Complete):
171− item, i := c.listView.GetSelectedItem()
172− if i == -1 {
173− return c, nil
174− }
175−
176− cmd := c.complete(item)
177−
178− return c, cmd
179− case key.Matches(msg, completionDialogKeys.Cancel):
180− // Only close on backspace when there are no characters left
181− if msg.String() != "backspace" || len(c.pseudoSearchTextArea.Value()) <= 0 {
182− return c, c.close()
183− }
184− }
185−
186− return c, tea.Batch(cmds...)
187− } else {
188− items, err := c.completionProvider.GetChildEntries("")
189− if err != nil {
190− logging.Error("Failed to get child entries", err)
191− }
192−
193− c.listView.SetItems(items)
194− c.pseudoSearchTextArea.SetValue(msg.String())
195− return c, c.pseudoSearchTextArea.Focus()
196− }
197− case tea.WindowSizeMsg:
198− c.width = msg.Width
199− c.height = msg.Height
200− }
201−
202− return c, tea.Batch(cmds...)
203−}
204−
205−func (c *completionDialogCmp) View() tea.View {
206− t := theme.CurrentTheme()
207− baseStyle := styles.BaseStyle()
208−
209− maxWidth := 40
210−
211− completions := c.listView.GetItems()
212−
213− for _, cmd := range completions {
214− title := cmd.DisplayValue()
215− if len(title) > maxWidth-4 {
216− maxWidth = len(title) + 4
217− }
218− }
219−
220− c.listView.SetMaxWidth(maxWidth)
221−
222− return tea.NewView(
223− baseStyle.Padding(0, 0).
224− Border(lipgloss.NormalBorder()).
225− BorderBottom(false).
226− BorderRight(false).
227− BorderLeft(false).
228− BorderBackground(t.Background()).
229− BorderForeground(t.TextMuted()).
230− Width(c.width).
231− Render(c.listView.View().String()),
232− )
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/dialog/custom_commands.godeleted−185
@@ -1,185 +0,0 @@
1−package dialog
2−
3−import (
4− "fmt"
5− "os"
6− "path/filepath"
7− "regexp"
8− "strings"
9−
10− tea "github.com/charmbracelet/bubbletea/v2"
11− "github.com/opencode-ai/opencode/internal/config"
12− "github.com/opencode-ai/opencode/internal/tui/util"
13−)
14−
15−// Command prefix constants
16−const (
17− UserCommandPrefix = "user:"
18− ProjectCommandPrefix = "project:"
19−)
20−
21−// namedArgPattern is a regex pattern to find named arguments in the format $NAME
22−var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
23−
24−// LoadCustomCommands loads custom commands from both XDG_CONFIG_HOME and project data directory
25−func LoadCustomCommands() ([]Command, error) {
26− cfg := config.Get()
27− if cfg == nil {
28− return nil, fmt.Errorf("config not loaded")
29− }
30−
31− var commands []Command
32−
33− // Load user commands from XDG_CONFIG_HOME/opencode/commands
34− xdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
35− if xdgConfigHome == "" {
36− // Default to ~/.config if XDG_CONFIG_HOME is not set
37− home, err := os.UserHomeDir()
38− if err == nil {
39− xdgConfigHome = filepath.Join(home, ".config")
40− }
41− }
42−
43− if xdgConfigHome != "" {
44− userCommandsDir := filepath.Join(xdgConfigHome, "opencode", "commands")
45− userCommands, err := loadCommandsFromDir(userCommandsDir, UserCommandPrefix)
46− if err != nil {
47− // Log error but continue - we'll still try to load other commands
48− fmt.Printf("Warning: failed to load user commands from XDG_CONFIG_HOME: %v\n", err)
49− } else {
50− commands = append(commands, userCommands...)
51− }
52− }
53−
54− // Load commands from $HOME/.opencode/commands
55− home, err := os.UserHomeDir()
56− if err == nil {
57− homeCommandsDir := filepath.Join(home, ".opencode", "commands")
58− homeCommands, err := loadCommandsFromDir(homeCommandsDir, UserCommandPrefix)
59− if err != nil {
60− // Log error but continue - we'll still try to load other commands
61− fmt.Printf("Warning: failed to load home commands: %v\n", err)
62− } else {
63− commands = append(commands, homeCommands...)
64− }
65− }
66−
67− // Load project commands from data directory
68− projectCommandsDir := filepath.Join(cfg.Data.Directory, "commands")
69− projectCommands, err := loadCommandsFromDir(projectCommandsDir, ProjectCommandPrefix)
70− if err != nil {
71− // Log error but return what we have so far
72− fmt.Printf("Warning: failed to load project commands: %v\n", err)
73− } else {
74− commands = append(commands, projectCommands...)
75− }
76−
77− return commands, nil
78−}
79−
80−// loadCommandsFromDir loads commands from a specific directory with the given prefix
81−func loadCommandsFromDir(commandsDir string, prefix string) ([]Command, error) {
82− // Check if the commands directory exists
83− if _, err := os.Stat(commandsDir); os.IsNotExist(err) {
84− // Create the commands directory if it doesn't exist
85− if err := os.MkdirAll(commandsDir, 0o755); err != nil {
86− return nil, fmt.Errorf("failed to create commands directory %s: %w", commandsDir, err)
87− }
88− // Return empty list since we just created the directory
89− return []Command{}, nil
90− }
91−
92− var commands []Command
93−
94− // Walk through the commands directory and load all .md files
95− err := filepath.Walk(commandsDir, func(path string, info os.FileInfo, err error) error {
96− if err != nil {
97− return err
98− }
99−
100− // Skip directories
101− if info.IsDir() {
102− return nil
103− }
104−
105− // Only process markdown files
106− if !strings.HasSuffix(strings.ToLower(info.Name()), ".md") {
107− return nil
108− }
109−
110− // Read the file content
111− content, err := os.ReadFile(path)
112− if err != nil {
113− return fmt.Errorf("failed to read command file %s: %w", path, err)
114− }
115−
116− // Get the command ID from the file name without the .md extension
117− commandID := strings.TrimSuffix(info.Name(), filepath.Ext(info.Name()))
118−
119− // Get relative path from commands directory
120− relPath, err := filepath.Rel(commandsDir, path)
121− if err != nil {
122− return fmt.Errorf("failed to get relative path for %s: %w", path, err)
123− }
124−
125− // Create the command ID from the relative path
126− // Replace directory separators with colons
127− commandIDPath := strings.ReplaceAll(filepath.Dir(relPath), string(filepath.Separator), ":")
128− if commandIDPath != "." {
129− commandID = commandIDPath + ":" + commandID
130− }
131−
132− // Create a command
133− command := Command{
134− ID: prefix + commandID,
135− Title: prefix + commandID,
136− Description: fmt.Sprintf("Custom command from %s", relPath),
137− Handler: func(cmd Command) tea.Cmd {
138− commandContent := string(content)
139−
140− // Check for named arguments
141− matches := namedArgPattern.FindAllStringSubmatch(commandContent, -1)
142− if len(matches) > 0 {
143− // Extract unique argument names
144− argNames := make([]string, 0)
145− argMap := make(map[string]bool)
146−
147− for _, match := range matches {
148− argName := match[1] // Group 1 is the name without $
149− if !argMap[argName] {
150− argMap[argName] = true
151− argNames = append(argNames, argName)
152− }
153− }
154−
155− // Show multi-arguments dialog for all named arguments
156− return util.CmdHandler(ShowMultiArgumentsDialogMsg{
157− CommandID: cmd.ID,
158− Content: commandContent,
159− ArgNames: argNames,
160− })
161− }
162−
163− // No arguments needed, run command directly
164− return util.CmdHandler(CommandRunCustomMsg{
165− Content: commandContent,
166− Args: nil, // No arguments
167− })
168− },
169− }
170−
171− commands = append(commands, command)
172− return nil
173− })
174− if err != nil {
175− return nil, fmt.Errorf("failed to load custom commands from %s: %w", commandsDir, err)
176− }
177−
178− return commands, nil
179−}
180−
181−// CommandRunCustomMsg is sent when a custom command is executed
182−type CommandRunCustomMsg struct {
183− Content string
184− Args map[string]string // Map of argument names to values
185−}
internal/tui/components/dialog/custom_commands_test.godeleted−106
@@ -1,106 +0,0 @@
1−package dialog
2−
3−import (
4− "regexp"
5− "testing"
6−)
7−
8−func TestNamedArgPattern(t *testing.T) {
9− testCases := []struct {
10− input string
11− expected []string
12− }{
13− {
14− input: "This is a test with $ARGUMENTS placeholder",
15− expected: []string{"ARGUMENTS"},
16− },
17− {
18− input: "This is a test with $FOO and $BAR placeholders",
19− expected: []string{"FOO", "BAR"},
20− },
21− {
22− input: "This is a test with $FOO_BAR and $BAZ123 placeholders",
23− expected: []string{"FOO_BAR", "BAZ123"},
24− },
25− {
26− input: "This is a test with no placeholders",
27− expected: []string{},
28− },
29− {
30− input: "This is a test with $FOO appearing twice: $FOO",
31− expected: []string{"FOO"},
32− },
33− {
34− input: "This is a test with $1INVALID placeholder",
35− expected: []string{},
36− },
37− }
38−
39− for _, tc := range testCases {
40− matches := namedArgPattern.FindAllStringSubmatch(tc.input, -1)
41−
42− // Extract unique argument names
43− argNames := make([]string, 0)
44− argMap := make(map[string]bool)
45−
46− for _, match := range matches {
47− argName := match[1] // Group 1 is the name without $
48− if !argMap[argName] {
49− argMap[argName] = true
50− argNames = append(argNames, argName)
51− }
52− }
53−
54− // Check if we got the expected number of arguments
55− if len(argNames) != len(tc.expected) {
56− t.Errorf("Expected %d arguments, got %d for input: %s", len(tc.expected), len(argNames), tc.input)
57− continue
58− }
59−
60− // Check if we got the expected argument names
61− for _, expectedArg := range tc.expected {
62− found := false
63− for _, actualArg := range argNames {
64− if actualArg == expectedArg {
65− found = true
66− break
67− }
68− }
69− if !found {
70− t.Errorf("Expected argument %s not found in %v for input: %s", expectedArg, argNames, tc.input)
71− }
72− }
73− }
74−}
75−
76−func TestRegexPattern(t *testing.T) {
77− pattern := regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
78−
79− validMatches := []string{
80− "$FOO",
81− "$BAR",
82− "$FOO_BAR",
83− "$BAZ123",
84− "$ARGUMENTS",
85− }
86−
87− invalidMatches := []string{
88− "$foo",
89− "$1BAR",
90− "$_FOO",
91− "FOO",
92− "$",
93− }
94−
95− for _, valid := range validMatches {
96− if !pattern.MatchString(valid) {
97− t.Errorf("Expected %s to match, but it didn't", valid)
98− }
99− }
100−
101− for _, invalid := range invalidMatches {
102− if pattern.MatchString(invalid) {
103− t.Errorf("Expected %s not to match, but it did", invalid)
104− }
105− }
106−}
internal/tui/components/dialogs/commands/commands.go+5−4
@@ -6,6 +6,7 @@ import (
66 "github.com/charmbracelet/lipgloss/v2"
77
88 "github.com/opencode-ai/opencode/internal/tui/components/chat"
9+ "github.com/opencode-ai/opencode/internal/tui/components/completions"
910 "github.com/opencode-ai/opencode/internal/tui/components/core/list"
1011 "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
1112 "github.com/opencode-ai/opencode/internal/tui/styles"
@@ -75,9 +76,9 @@ func (c *commandDialogCmp) Init() tea.Cmd {
7576
7677 commandItems := []util.Model{}
7778 if len(commands) > 0 {
78− commandItems = append(commandItems, NewItemSection("Custom"))
79+ commandItems = append(commandItems, NewItemSection("Custom Commands"))
7980 for _, cmd := range commands {
80− commandItems = append(commandItems, NewCommandItem(cmd))
81+ commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd))
8182 }
8283 }
8384
@@ -85,7 +86,7 @@ func (c *commandDialogCmp) Init() tea.Cmd {
8586
8687 for _, cmd := range c.defaultCommands() {
8788 c.commands = append(c.commands, cmd)
88− commandItems = append(commandItems, NewCommandItem(cmd))
89+ commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd))
8990 }
9091
9192 c.commandList.SetItems(commandItems)
@@ -106,7 +107,7 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
106107 return c, nil // No item selected, do nothing
107108 }
108109 items := c.commandList.Items()
109− selectedItem := items[selectedItemInx].(CommandItem).Command()
110+ selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(Command)
110111 return c, tea.Sequence(
111112 util.CmdHandler(dialogs.CloseDialogMsg{}),
112113 selectedItem.Handler(selectedItem),
internal/tui/components/dialogs/commands/item.go−145
@@ -11,153 +11,8 @@ import (
1111 "github.com/opencode-ai/opencode/internal/tui/styles"
1212 "github.com/opencode-ai/opencode/internal/tui/theme"
1313 "github.com/opencode-ai/opencode/internal/tui/util"
14− "github.com/rivo/uniseg"
1514)
1615
17−type CommandItem interface {
18− util.Model
19− layout.Focusable
20− layout.Sizeable
21− Command() Command
22−}
23−
24−type commandItem struct {
25− width int
26− command Command
27− focus bool
28− matchIndexes []int
29−}
30−
31−func NewCommandItem(command Command) CommandItem {
32− return &commandItem{
33− command: command,
34− matchIndexes: make([]int, 0),
35− }
36−}
37−
38−// Init implements CommandItem.
39−func (c *commandItem) Init() tea.Cmd {
40− return nil
41−}
42−
43−// Update implements CommandItem.
44−func (c *commandItem) Update(tea.Msg) (tea.Model, tea.Cmd) {
45− return c, nil
46−}
47−
48−// View implements CommandItem.
49−func (c *commandItem) View() tea.View {
50− t := theme.CurrentTheme()
51−
52− baseStyle := styles.BaseStyle()
53− titleStyle := baseStyle.Width(c.width).Foreground(t.Text())
54− titleMatchStyle := baseStyle.Foreground(t.Text()).Underline(true)
55−
56− if c.focus {
57− titleStyle = titleStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true)
58− titleMatchStyle = titleMatchStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true)
59− }
60− var ranges []lipgloss.Range
61− truncatedTitle := ansi.Truncate(c.command.Title, c.width, "…")
62− text := titleStyle.Render(truncatedTitle)
63− if len(c.matchIndexes) > 0 {
64− for _, rng := range matchedRanges(c.matchIndexes) {
65− // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
66− // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
67− // so we need to adjust it here:
68− start, stop := bytePosToVisibleCharPos(text, rng)
69− ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
70− }
71− text = lipgloss.StyleRanges(text, ranges...)
72− }
73− return tea.NewView(text)
74−}
75−
76−// Command implements CommandItem.
77−func (c *commandItem) Command() Command {
78− return c.command
79−}
80−
81−// Blur implements CommandItem.
82−func (c *commandItem) Blur() tea.Cmd {
83− c.focus = false
84− return nil
85−}
86−
87−// Focus implements CommandItem.
88−func (c *commandItem) Focus() tea.Cmd {
89− c.focus = true
90− return nil
91−}
92−
93−// IsFocused implements CommandItem.
94−func (c *commandItem) IsFocused() bool {
95− return c.focus
96−}
97−
98−// GetSize implements CommandItem.
99−func (c *commandItem) GetSize() (int, int) {
100− return c.width, 2
101−}
102−
103−// SetSize implements CommandItem.
104−func (c *commandItem) SetSize(width int, height int) tea.Cmd {
105− c.width = width
106− return nil
107−}
108−
109−func (c *commandItem) FilterValue() string {
110− return c.command.Title
111−}
112−
113−func (c *commandItem) MatchIndexes(indexes []int) {
114− c.matchIndexes = indexes
115−}
116−
117−func matchedRanges(in []int) [][2]int {
118− if len(in) == 0 {
119− return [][2]int{}
120− }
121− current := [2]int{in[0], in[0]}
122− if len(in) == 1 {
123− return [][2]int{current}
124− }
125− var out [][2]int
126− for i := 1; i < len(in); i++ {
127− if in[i] == current[1]+1 {
128− current[1] = in[i]
129− } else {
130− out = append(out, current)
131− current = [2]int{in[i], in[i]}
132− }
133− }
134− out = append(out, current)
135− return out
136−}
137−
138−func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
139− bytePos, byteStart, byteStop := 0, rng[0], rng[1]
140− pos, start, stop := 0, 0, 0
141− gr := uniseg.NewGraphemes(str)
142− for byteStart > bytePos {
143− if !gr.Next() {
144− break
145− }
146− bytePos += len(gr.Str())
147− pos += max(1, gr.Width())
148− }
149− start = pos
150− for byteStop > bytePos {
151− if !gr.Next() {
152− break
153− }
154− bytePos += len(gr.Str())
155− pos += max(1, gr.Width())
156− }
157− stop = pos
158− return start, stop
159−}
160−
16116type ItemSection interface {
16217 util.Model
16318 layout.Sizeable
internal/tui/page/chat.go+17−85
@@ -2,18 +2,16 @@ package page
22
33import (
44 "context"
5− "strings"
65
76 "github.com/charmbracelet/bubbles/v2/key"
87 tea "github.com/charmbracelet/bubbletea/v2"
9− "github.com/charmbracelet/lipgloss/v2"
108 "github.com/opencode-ai/opencode/internal/app"
11− "github.com/opencode-ai/opencode/internal/completions"
129 "github.com/opencode-ai/opencode/internal/logging"
1310 "github.com/opencode-ai/opencode/internal/message"
1411 "github.com/opencode-ai/opencode/internal/session"
1512 "github.com/opencode-ai/opencode/internal/tui/components/chat"
16− "github.com/opencode-ai/opencode/internal/tui/components/dialog"
13+ "github.com/opencode-ai/opencode/internal/tui/components/chat/editor"
14+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
1715 "github.com/opencode-ai/opencode/internal/tui/layout"
1816 "github.com/opencode-ai/opencode/internal/tui/util"
1917)
@@ -21,26 +19,19 @@ import (
2119var ChatPage PageID = "chat"
2220
2321type chatPage struct {
24− app *app.App
25− editor layout.Container
26− messages layout.Container
27− layout layout.SplitPaneLayout
28− session session.Session
29− completionDialog dialog.CompletionDialog
30− showCompletionDialog bool
22+ app *app.App
23+ editor layout.Container
24+ messages layout.Container
25+ layout layout.SplitPaneLayout
26+ session session.Session
3127}
3228
3329type ChatKeyMap struct {
34− ShowCompletionDialog key.Binding
35− NewSession key.Binding
36− Cancel key.Binding
30+ NewSession key.Binding
31+ Cancel key.Binding
3732}
3833
3934var keyMap = ChatKeyMap{
40− ShowCompletionDialog: key.NewBinding(
41− key.WithKeys("@"),
42− key.WithHelp("@", "Complete"),
43− ),
4435 NewSession: key.NewBinding(
4536 key.WithKeys("ctrl+n"),
4637 key.WithHelp("ctrl+n", "new session"),
@@ -52,11 +43,7 @@ var keyMap = ChatKeyMap{
5243}
5344
5445func (p *chatPage) Init() tea.Cmd {
55− cmds := []tea.Cmd{
56− p.layout.Init(),
57− p.completionDialog.Init(),
58− }
59− return tea.Batch(cmds...)
46+ return p.layout.Init()
6047}
6148
6249func (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) {
6653 logging.Info("Window size changed Chat:", "Width", msg.Width, "Height", msg.Height)
6754 cmd := p.layout.SetSize(msg.Width, msg.Height)
6855 cmds = append(cmds, cmd)
69− case dialog.CompletionDialogCloseMsg:
70− p.showCompletionDialog = false
7156 case chat.SendMsg:
7257 cmd := p.sendMessage(msg.Text, msg.Attachments)
7358 if cmd != nil {
7459 return p, cmd
7560 }
76− case dialog.CommandRunCustomMsg:
61+ case commands.CommandRunCustomMsg:
7762 // Check if the agent is busy before executing custom commands
7863 if p.app.CoderAgent.IsBusy() {
7964 return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
8065 }
8166
82− // Process the command content with arguments if any
83− content := msg.Content
84− if msg.Args != nil {
85− // Replace all named arguments with their values
86− for name, value := range msg.Args {
87− placeholder := "$" + name
88− content = strings.ReplaceAll(content, placeholder, value)
89− }
90− }
91−
9267 // Handle custom command execution
93− cmd := p.sendMessage(content, nil)
68+ cmd := p.sendMessage(msg.Content, nil)
9469 if cmd != nil {
9570 return p, cmd
9671 }
@@ -104,9 +79,6 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
10479 p.session = msg
10580 case tea.KeyPressMsg:
10681 switch {
107− case key.Matches(msg, keyMap.ShowCompletionDialog):
108− p.showCompletionDialog = true
109− // Continue sending keys to layout->chat
11082 case key.Matches(msg, keyMap.NewSession):
11183 p.session = session.Session{}
11284 return p, tea.Batch(
@@ -122,19 +94,6 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
12294 }
12395 }
12496 }
125− if p.showCompletionDialog {
126− context, contextCmd := p.completionDialog.Update(msg)
127− p.completionDialog = context.(dialog.CompletionDialog)
128− cmds = append(cmds, contextCmd)
129−
130− // Doesn't forward event if enter key is pressed
131− if keyMsg, ok := msg.(tea.KeyPressMsg); ok {
132− if keyMsg.String() == "enter" {
133− return p, tea.Batch(cmds...)
134− }
135− }
136− }
137−
13897 u, cmd := p.layout.Update(msg)
13998 cmds = append(cmds, cmd)
14099 p.layout = u.(layout.SplitPaneLayout)
@@ -186,30 +145,7 @@ func (p *chatPage) GetSize() (int, int) {
186145}
187146
188147func (p *chatPage) View() tea.View {
189− layoutView := p.layout.View()
190−
191− if p.showCompletionDialog {
192− _, layoutHeight := p.layout.GetSize()
193− editorWidth, editorHeight := p.editor.GetSize()
194−
195− p.completionDialog.SetWidth(editorWidth)
196− overlay := p.completionDialog.View()
197−
198− viewStr := layout.PlaceOverlay(
199− 0,
200− layoutHeight-editorHeight-lipgloss.Height(overlay.String()),
201− overlay.String(),
202− layoutView.String(),
203− false,
204− )
205−
206− view := tea.NewView(viewStr)
207− view.SetCursor(overlay.Cursor())
208− return view
209− }
210−
211− logging.Info("Cursor in page", "c", layoutView.Cursor())
212− return layoutView
148+ return p.layout.View()
213149}
214150
215151func (p *chatPage) BindingKeys() []key.Binding {
@@ -220,22 +156,18 @@ func (p *chatPage) BindingKeys() []key.Binding {
220156}
221157
222158func NewChatPage(app *app.App) util.Model {
223− cg := completions.NewFileAndFolderContextGroup()
224− completionDialog := dialog.NewCompletionDialogCmp(cg)
225−
226159 messagesContainer := layout.NewContainer(
227160 chat.NewMessagesListCmp(app),
228161 layout.WithPadding(1, 1, 0, 1),
229162 )
230163 editorContainer := layout.NewContainer(
231− chat.NewEditorCmp(app),
164+ editor.NewEditorCmp(app),
232165 layout.WithBorder(true, false, false, false),
233166 )
234167 return &chatPage{
235− app: app,
236− editor: editorContainer,
237− messages: messagesContainer,
238− completionDialog: completionDialog,
168+ app: app,
169+ editor: editorContainer,
170+ messages: messagesContainer,
239171 layout: layout.NewSplitPane(
240172 layout.WithLeftPanel(messagesContainer),
241173 layout.WithBottomPanel(editorContainer),
internal/tui/tui.go+53−16
@@ -7,6 +7,7 @@ import (
77 "github.com/opencode-ai/opencode/internal/app"
88 "github.com/opencode-ai/opencode/internal/logging"
99 "github.com/opencode-ai/opencode/internal/pubsub"
10+ "github.com/opencode-ai/opencode/internal/tui/components/completions"
1011 "github.com/opencode-ai/opencode/internal/tui/components/core"
1112 "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
1213 "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
@@ -30,7 +31,8 @@ type appModel struct {
3031
3132 app *app.App
3233
33− dialog dialogs.DialogCmp
34+ dialog dialogs.DialogCmp
35+ completions completions.Completions
3436}
3537
3638func (a appModel) Init() tea.Cmd {
@@ -52,6 +54,12 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
5254 case tea.WindowSizeMsg:
5355 return a, a.handleWindowResize(msg)
5456
57+ // Completions messages
58+ case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg, completions.CloseCompletionsMsg:
59+ u, completionCmd := a.completions.Update(msg)
60+ a.completions = u.(completions.Completions)
61+ return a, completionCmd
62+
5563 // Dialog messages
5664 case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
5765 u, dialogCmd := a.dialog.Update(msg)
@@ -128,6 +136,24 @@ func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
128136
129137func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
130138 switch {
139+ // completions
140+ case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Up):
141+ u, cmd := a.completions.Update(msg)
142+ a.completions = u.(completions.Completions)
143+ return cmd
144+
145+ case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Down):
146+ u, cmd := a.completions.Update(msg)
147+ a.completions = u.(completions.Completions)
148+ return cmd
149+ case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Select):
150+ u, cmd := a.completions.Update(msg)
151+ a.completions = u.(completions.Completions)
152+ return cmd
153+ case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Cancel):
154+ u, cmd := a.completions.Update(msg)
155+ a.completions = u.(completions.Completions)
156+ return cmd
131157 // dialogs
132158 case key.Matches(msg, a.keyMap.Quit):
133159 return util.CmdHandler(dialogs.OpenDialogMsg{
@@ -191,28 +217,38 @@ func (a *appModel) View() tea.View {
191217 components = append(components, a.status.View().String())
192218
193219 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
194−
220+ layers := []*lipgloss.Layer{
221+ lipgloss.NewLayer(appView),
222+ }
195223 t := theme.CurrentTheme()
196224 if a.dialog.HasDialogs() {
197− layers := append(
198− []*lipgloss.Layer{
199− lipgloss.NewLayer(appView),
200− },
225+ layers = append(
226+ layers,
201227 a.dialog.GetLayers()...,
202228 )
203− canvas := lipgloss.NewCanvas(
204− layers...,
229+ }
230+
231+ cursor := pageView.Cursor()
232+ activeView := a.dialog.ActiveView()
233+ if activeView != nil {
234+ cursor = activeView.Cursor()
235+ }
236+
237+ if a.completions.Open() && cursor != nil {
238+ cmp := a.completions.View().String()
239+ x, y := a.completions.Position()
240+ layers = append(
241+ layers,
242+ lipgloss.NewLayer(cmp).X(x).Y(y),
205243 )
206− view := tea.NewView(canvas.Render())
207− activeView := a.dialog.ActiveView()
208− view.SetBackgroundColor(t.Background())
209− view.SetCursor(activeView.Cursor())
210− return view
211244 }
212245
213− view := tea.NewView(appView)
214− view.SetCursor(pageView.Cursor())
246+ canvas := lipgloss.NewCanvas(
247+ layers...,
248+ )
249+ view := tea.NewView(canvas.Render())
215250 view.SetBackgroundColor(t.Background())
251+ view.SetCursor(cursor)
216252 return view
217253}
218254
@@ -230,7 +266,8 @@ func New(app *app.App) tea.Model {
230266 page.LogsPage: page.NewLogsPage(),
231267 },
232268
233− dialog: dialogs.NewDialogCmp(),
269+ dialog: dialogs.NewDialogCmp(),
270+ completions: completions.New(),
234271 }
235272
236273 return model