Detailed changes
@@ -35,8 +35,6 @@ require (
require (
cloud.google.com/go v0.116.0 // indirect
- github.com/google/go-cmp v0.7.0 // indirect
- github.com/gorilla/websocket v1.5.3 // indirect
cloud.google.com/go/auth v0.13.0 // indirect
cloud.google.com/go/compute/metadata v0.6.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
@@ -72,12 +70,15 @@ require (
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
+ github.com/google/go-cmp v0.7.0 // indirect
github.com/google/s2a-go v0.1.8 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/gorilla/css v1.0.1 // indirect
+ github.com/gorilla/websocket v1.5.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
+ github.com/lithammer/fuzzysearch v1.1.8
github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
@@ -144,6 +144,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
+github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms=
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231/go.mod h1:S5etECMx+sZnW0Gm100Ma9J1PgVCTgNyFaqGu2b08b4=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
@@ -0,0 +1,191 @@
+package completions
+
+import (
+ "bytes"
+ "fmt"
+ "os/exec"
+ "path/filepath"
+
+ "github.com/lithammer/fuzzysearch/fuzzy"
+ "github.com/opencode-ai/opencode/internal/fileutil"
+ "github.com/opencode-ai/opencode/internal/logging"
+ "github.com/opencode-ai/opencode/internal/tui/components/dialog"
+)
+
+type filesAndFoldersContextGroup struct {
+ prefix string
+}
+
+func (cg *filesAndFoldersContextGroup) GetId() string {
+ return cg.prefix
+}
+
+func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI {
+ return dialog.NewCompletionItem(dialog.CompletionItem{
+ Title: "Files & Folders",
+ Value: "files",
+ })
+}
+
+func processNullTerminatedOutput(outputBytes []byte) []string {
+ if len(outputBytes) > 0 && outputBytes[len(outputBytes)-1] == 0 {
+ outputBytes = outputBytes[:len(outputBytes)-1]
+ }
+
+ if len(outputBytes) == 0 {
+ return []string{}
+ }
+
+ split := bytes.Split(outputBytes, []byte{0})
+ matches := make([]string, 0, len(split))
+
+ for _, p := range split {
+ if len(p) == 0 {
+ continue
+ }
+
+ path := string(p)
+ path = filepath.Join(".", path)
+
+ if !fileutil.SkipHidden(path) {
+ matches = append(matches, path)
+ }
+ }
+
+ return matches
+}
+
+func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) {
+ cmdRg := fileutil.GetRgCmd("") // No glob pattern for this use case
+ cmdFzf := fileutil.GetFzfCmd(query)
+
+ var matches []string
+ // Case 1: Both rg and fzf available
+ if cmdRg != nil && cmdFzf != nil {
+ rgPipe, err := cmdRg.StdoutPipe()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get rg stdout pipe: %w", err)
+ }
+ defer rgPipe.Close()
+
+ cmdFzf.Stdin = rgPipe
+ var fzfOut bytes.Buffer
+ var fzfErr bytes.Buffer
+ cmdFzf.Stdout = &fzfOut
+ cmdFzf.Stderr = &fzfErr
+
+ if err := cmdFzf.Start(); err != nil {
+ return nil, fmt.Errorf("failed to start fzf: %w", err)
+ }
+
+ errRg := cmdRg.Run()
+ errFzf := cmdFzf.Wait()
+
+ if errRg != nil {
+ logging.Warn(fmt.Sprintf("rg command failed during pipe: %v", errRg))
+ }
+
+ if errFzf != nil {
+ if exitErr, ok := errFzf.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
+ return []string{}, nil // No matches from fzf
+ }
+ return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", errFzf, fzfErr.String())
+ }
+
+ matches = processNullTerminatedOutput(fzfOut.Bytes())
+
+ // Case 2: Only rg available
+ } else if cmdRg != nil {
+ logging.Debug("Using Ripgrep with fuzzy match fallback for file completions")
+ var rgOut bytes.Buffer
+ var rgErr bytes.Buffer
+ cmdRg.Stdout = &rgOut
+ cmdRg.Stderr = &rgErr
+
+ if err := cmdRg.Run(); err != nil {
+ return nil, fmt.Errorf("rg command failed: %w\nStderr: %s", err, rgErr.String())
+ }
+
+ allFiles := processNullTerminatedOutput(rgOut.Bytes())
+ matches = fuzzy.Find(query, allFiles)
+
+ // Case 3: Only fzf available
+ } else if cmdFzf != nil {
+ logging.Debug("Using FZF with doublestar fallback for file completions")
+ files, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list files for fzf: %w", err)
+ }
+
+ allFiles := make([]string, 0, len(files))
+ for _, file := range files {
+ if !fileutil.SkipHidden(file) {
+ allFiles = append(allFiles, file)
+ }
+ }
+
+ var fzfIn bytes.Buffer
+ for _, file := range allFiles {
+ fzfIn.WriteString(file)
+ fzfIn.WriteByte(0)
+ }
+
+ cmdFzf.Stdin = &fzfIn
+ var fzfOut bytes.Buffer
+ var fzfErr bytes.Buffer
+ cmdFzf.Stdout = &fzfOut
+ cmdFzf.Stderr = &fzfErr
+
+ if err := cmdFzf.Run(); err != nil {
+ if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
+ return []string{}, nil
+ }
+ return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", err, fzfErr.String())
+ }
+
+ matches = processNullTerminatedOutput(fzfOut.Bytes())
+
+ // Case 4: Fallback to doublestar with fuzzy match
+ } else {
+ logging.Debug("Using doublestar with fuzzy match for file completions")
+ allFiles, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0)
+ if err != nil {
+ return nil, fmt.Errorf("failed to glob files: %w", err)
+ }
+
+ filteredFiles := make([]string, 0, len(allFiles))
+ for _, file := range allFiles {
+ if !fileutil.SkipHidden(file) {
+ filteredFiles = append(filteredFiles, file)
+ }
+ }
+
+ matches = fuzzy.Find(query, filteredFiles)
+ }
+
+ return matches, nil
+}
+
+func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
+ matches, err := cg.getFiles(query)
+ if err != nil {
+ return nil, err
+ }
+
+ items := make([]dialog.CompletionItemI, 0, len(matches))
+ for _, file := range matches {
+ item := dialog.NewCompletionItem(dialog.CompletionItem{
+ Title: file,
+ Value: file,
+ })
+ items = append(items, item)
+ }
+
+ return items, nil
+}
+
+func NewFileAndFolderContextGroup() dialog.CompletionProvider {
+ return &filesAndFoldersContextGroup{
+ prefix: "file",
+ }
+}
@@ -0,0 +1,163 @@
+package fileutil
+
+import (
+ "fmt"
+ "io/fs"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/bmatcuk/doublestar/v4"
+ "github.com/opencode-ai/opencode/internal/logging"
+)
+
+var (
+ rgPath string
+ fzfPath string
+)
+
+func init() {
+ var err error
+ rgPath, err = exec.LookPath("rg")
+ if err != nil {
+ logging.Warn("Ripgrep (rg) not found in $PATH. Some features might be limited or slower.")
+ rgPath = ""
+ }
+ fzfPath, err = exec.LookPath("fzf")
+ if err != nil {
+ logging.Warn("FZF not found in $PATH. Some features might be limited or slower.")
+ fzfPath = ""
+ }
+}
+
+func GetRgCmd(globPattern string) *exec.Cmd {
+ if rgPath == "" {
+ return nil
+ }
+ rgArgs := []string{
+ "--files",
+ "-L",
+ "--null",
+ }
+ if globPattern != "" {
+ if !filepath.IsAbs(globPattern) && !strings.HasPrefix(globPattern, "/") {
+ globPattern = "/" + globPattern
+ }
+ rgArgs = append(rgArgs, "--glob", globPattern)
+ }
+ cmd := exec.Command(rgPath, rgArgs...)
+ cmd.Dir = "."
+ return cmd
+}
+
+func GetFzfCmd(query string) *exec.Cmd {
+ if fzfPath == "" {
+ return nil
+ }
+ fzfArgs := []string{
+ "--filter",
+ query,
+ "--read0",
+ "--print0",
+ }
+ cmd := exec.Command(fzfPath, fzfArgs...)
+ cmd.Dir = "."
+ return cmd
+}
+
+type FileInfo struct {
+ Path string
+ ModTime time.Time
+}
+
+func SkipHidden(path string) bool {
+ // Check for hidden files (starting with a dot)
+ base := filepath.Base(path)
+ if base != "." && strings.HasPrefix(base, ".") {
+ return true
+ }
+
+ commonIgnoredDirs := map[string]bool{
+ ".opencode": true,
+ "node_modules": true,
+ "vendor": true,
+ "dist": true,
+ "build": true,
+ "target": true,
+ ".git": true,
+ ".idea": true,
+ ".vscode": true,
+ "__pycache__": true,
+ "bin": true,
+ "obj": true,
+ "out": true,
+ "coverage": true,
+ "tmp": true,
+ "temp": true,
+ "logs": true,
+ "generated": true,
+ "bower_components": true,
+ "jspm_packages": true,
+ }
+
+ parts := strings.Split(path, string(os.PathSeparator))
+ for _, part := range parts {
+ if commonIgnoredDirs[part] {
+ return true
+ }
+ }
+ return false
+}
+
+func GlobWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) {
+ fsys := os.DirFS(searchPath)
+ relPattern := strings.TrimPrefix(pattern, "/")
+ var matches []FileInfo
+
+ err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
+ if d.IsDir() {
+ return nil
+ }
+ if SkipHidden(path) {
+ return nil
+ }
+ info, err := d.Info()
+ if err != nil {
+ return nil
+ }
+ absPath := path
+ if !strings.HasPrefix(absPath, searchPath) && searchPath != "." {
+ absPath = filepath.Join(searchPath, absPath)
+ } else if !strings.HasPrefix(absPath, "/") && searchPath == "." {
+ absPath = filepath.Join(searchPath, absPath) // Ensure relative paths are joined correctly
+ }
+
+ matches = append(matches, FileInfo{Path: absPath, ModTime: info.ModTime()})
+ if limit > 0 && len(matches) >= limit*2 {
+ return fs.SkipAll
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, false, fmt.Errorf("glob walk error: %w", err)
+ }
+
+ sort.Slice(matches, func(i, j int) bool {
+ return matches[i].ModTime.After(matches[j].ModTime)
+ })
+
+ truncated := false
+ if limit > 0 && len(matches) > limit {
+ matches = matches[:limit]
+ truncated = true
+ }
+
+ results := make([]string, len(matches))
+ for i, m := range matches {
+ results[i] = m.Path
+ }
+ return results, truncated, nil
+}
@@ -5,16 +5,14 @@ import (
"context"
"encoding/json"
"fmt"
- "io/fs"
- "os"
"os/exec"
"path/filepath"
"sort"
"strings"
- "time"
- "github.com/bmatcuk/doublestar/v4"
"github.com/opencode-ai/opencode/internal/config"
+ "github.com/opencode-ai/opencode/internal/fileutil"
+ "github.com/opencode-ai/opencode/internal/logging"
)
const (
@@ -55,11 +53,6 @@ TIPS:
- Always check if results are truncated and refine your search pattern if needed`
)
-type fileInfo struct {
- path string
- modTime time.Time
-}
-
type GlobParams struct {
Pattern string `json:"pattern"`
Path string `json:"path"`
@@ -134,41 +127,20 @@ func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
}
func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
- matches, err := globWithRipgrep(pattern, searchPath, limit)
- if err == nil {
- return matches, len(matches) >= limit, nil
+ cmdRg := fileutil.GetRgCmd(pattern)
+ if cmdRg != nil {
+ cmdRg.Dir = searchPath
+ matches, err := runRipgrep(cmdRg, searchPath, limit)
+ if err == nil {
+ return matches, len(matches) >= limit && limit > 0, nil
+ }
+ logging.Warn(fmt.Sprintf("Ripgrep execution failed: %v. Falling back to doublestar.", err))
}
- return globWithDoublestar(pattern, searchPath, limit)
+ return fileutil.GlobWithDoublestar(pattern, searchPath, limit)
}
-func globWithRipgrep(
- pattern, searchRoot string,
- limit int,
-) ([]string, error) {
- if searchRoot == "" {
- searchRoot = "."
- }
-
- rgBin, err := exec.LookPath("rg")
- if err != nil {
- return nil, fmt.Errorf("ripgrep not found in $PATH: %w", err)
- }
-
- if !filepath.IsAbs(pattern) && !strings.HasPrefix(pattern, "/") {
- pattern = "/" + pattern
- }
-
- args := []string{
- "--files",
- "--null",
- "--glob", pattern,
- "-L",
- }
-
- cmd := exec.Command(rgBin, args...)
- cmd.Dir = searchRoot
-
+func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) {
out, err := cmd.CombinedOutput()
if err != nil {
if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
@@ -182,117 +154,22 @@ func globWithRipgrep(
if len(p) == 0 {
continue
}
- abs := filepath.Join(searchRoot, string(p))
- if skipHidden(abs) {
+ absPath := string(p)
+ if !filepath.IsAbs(absPath) {
+ absPath = filepath.Join(searchRoot, absPath)
+ }
+ if fileutil.SkipHidden(absPath) {
continue
}
- matches = append(matches, abs)
+ matches = append(matches, absPath)
}
sort.SliceStable(matches, func(i, j int) bool {
return len(matches[i]) < len(matches[j])
})
- if len(matches) > limit {
+ if limit > 0 && len(matches) > limit {
matches = matches[:limit]
}
return matches, nil
}
-
-func globWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) {
- fsys := os.DirFS(searchPath)
-
- relPattern := strings.TrimPrefix(pattern, "/")
-
- var matches []fileInfo
-
- err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
- if d.IsDir() {
- return nil
- }
- if skipHidden(path) {
- return nil
- }
-
- info, err := d.Info()
- if err != nil {
- return nil // Skip files we can't access
- }
-
- absPath := path // Restore absolute path
- if !strings.HasPrefix(absPath, searchPath) {
- absPath = filepath.Join(searchPath, absPath)
- }
-
- matches = append(matches, fileInfo{
- path: absPath,
- modTime: info.ModTime(),
- })
-
- if len(matches) >= limit*2 { // Collect more than needed for sorting
- return fs.SkipAll
- }
-
- return nil
- })
- if err != nil {
- return nil, false, fmt.Errorf("glob walk error: %w", err)
- }
-
- sort.Slice(matches, func(i, j int) bool {
- return matches[i].modTime.After(matches[j].modTime)
- })
-
- truncated := len(matches) > limit
- if truncated {
- matches = matches[:limit]
- }
-
- results := make([]string, len(matches))
- for i, m := range matches {
- results[i] = m.path
- }
-
- return results, truncated, nil
-}
-
-func skipHidden(path string) bool {
- // Check for hidden files (starting with a dot)
- base := filepath.Base(path)
- if base != "." && strings.HasPrefix(base, ".") {
- return true
- }
-
- // List of commonly ignored directories in development projects
- commonIgnoredDirs := map[string]bool{
- "node_modules": true,
- "vendor": true,
- "dist": true,
- "build": true,
- "target": true,
- ".git": true,
- ".idea": true,
- ".vscode": true,
- "__pycache__": true,
- "bin": true,
- "obj": true,
- "out": true,
- "coverage": true,
- "tmp": true,
- "temp": true,
- "logs": true,
- "generated": true,
- "bower_components": true,
- "jspm_packages": true,
- }
-
- // Check if any path component is in our ignore list
- parts := strings.SplitSeq(path, string(os.PathSeparator))
- for part := range parts {
- if commonIgnoredDirs[part] {
- return true
- }
- }
-
- return false
-}
@@ -15,6 +15,7 @@ import (
"time"
"github.com/opencode-ai/opencode/internal/config"
+ "github.com/opencode-ai/opencode/internal/fileutil"
)
type GrepParams struct {
@@ -288,7 +289,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
return nil // Skip directories
}
- if skipHidden(path) {
+ if fileutil.SkipHidden(path) {
return nil
}
@@ -5,6 +5,7 @@ import (
"os"
"os/exec"
"slices"
+ "strings"
"unicode"
"github.com/charmbracelet/bubbles/key"
@@ -144,6 +145,11 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case dialog.ThemeChangedMsg:
m.textarea = CreateTextArea(&m.textarea)
+ case dialog.CompletionSelectedMsg:
+ existingValue := m.textarea.Value()
+ modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
+
+ m.textarea.SetValue(modifiedValue)
return m, nil
case SessionSelectedMsg:
if msg.ID != m.session.ID {
@@ -192,7 +198,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.deleteMode = false
return m, nil
}
- // Handle Enter key
+ // Hanlde Enter key
if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
value := m.textarea.Value()
if len(value) > 0 && value[len(value)-1] == '\\' {
@@ -4,6 +4,7 @@ import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
+ utilComponents "github.com/opencode-ai/opencode/internal/tui/components/util"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
@@ -18,6 +19,33 @@ type Command struct {
Handler func(cmd Command) tea.Cmd
}
+func (ci Command) Render(selected bool, width int) string {
+ t := theme.CurrentTheme()
+ baseStyle := styles.BaseStyle()
+
+ descStyle := baseStyle.Width(width).Foreground(t.TextMuted())
+ itemStyle := baseStyle.Width(width).
+ Foreground(t.Text()).
+ Background(t.Background())
+
+ if selected {
+ itemStyle = itemStyle.
+ Background(t.Primary()).
+ Foreground(t.Background()).
+ Bold(true)
+ descStyle = descStyle.
+ Background(t.Primary()).
+ Foreground(t.Background())
+ }
+
+ title := itemStyle.Padding(0, 1).Render(ci.Title)
+ if ci.Description != "" {
+ description := descStyle.Padding(0, 1).Render(ci.Description)
+ return lipgloss.JoinVertical(lipgloss.Left, title, description)
+ }
+ return title
+}
+
// CommandSelectedMsg is sent when a command is selected
type CommandSelectedMsg struct {
Command Command
@@ -31,35 +59,20 @@ type CommandDialog interface {
tea.Model
layout.Bindings
SetCommands(commands []Command)
- SetSelectedCommand(commandID string)
}
type commandDialogCmp struct {
- commands []Command
- selectedIdx int
- width int
- height int
- selectedCommandID string
+ listView utilComponents.SimpleList[Command]
+ width int
+ height int
}
type commandKeyMap struct {
- Up key.Binding
- Down key.Binding
Enter key.Binding
Escape key.Binding
- J key.Binding
- K key.Binding
}
var commandKeys = commandKeyMap{
- Up: key.NewBinding(
- key.WithKeys("up"),
- key.WithHelp("↑", "previous command"),
- ),
- Down: key.NewBinding(
- key.WithKeys("down"),
- key.WithHelp("↓", "next command"),
- ),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select command"),
@@ -68,38 +81,22 @@ var commandKeys = commandKeyMap{
key.WithKeys("esc"),
key.WithHelp("esc", "close"),
),
- J: key.NewBinding(
- key.WithKeys("j"),
- key.WithHelp("j", "next command"),
- ),
- K: key.NewBinding(
- key.WithKeys("k"),
- key.WithHelp("k", "previous command"),
- ),
}
func (c *commandDialogCmp) Init() tea.Cmd {
- return nil
+ return c.listView.Init()
}
func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
- case key.Matches(msg, commandKeys.Up) || key.Matches(msg, commandKeys.K):
- if c.selectedIdx > 0 {
- c.selectedIdx--
- }
- return c, nil
- case key.Matches(msg, commandKeys.Down) || key.Matches(msg, commandKeys.J):
- if c.selectedIdx < len(c.commands)-1 {
- c.selectedIdx++
- }
- return c, nil
case key.Matches(msg, commandKeys.Enter):
- if len(c.commands) > 0 {
+ selectedItem, idx := c.listView.GetSelectedItem()
+ if idx != -1 {
return c, util.CmdHandler(CommandSelectedMsg{
- Command: c.commands[c.selectedIdx],
+ Command: selectedItem,
})
}
case key.Matches(msg, commandKeys.Escape):
@@ -109,78 +106,35 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
c.width = msg.Width
c.height = msg.Height
}
- return c, nil
+
+ u, cmd := c.listView.Update(msg)
+ c.listView = u.(utilComponents.SimpleList[Command])
+ cmds = append(cmds, cmd)
+
+ return c, tea.Batch(cmds...)
}
func (c *commandDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
-
- if len(c.commands) == 0 {
- return baseStyle.Padding(1, 2).
- Border(lipgloss.RoundedBorder()).
- BorderBackground(t.Background()).
- BorderForeground(t.TextMuted()).
- Width(40).
- Render("No commands available")
- }
- // Calculate max width needed for command titles
- maxWidth := 40 // Minimum width
- for _, cmd := range c.commands {
- if len(cmd.Title) > maxWidth-4 { // Account for padding
- maxWidth = len(cmd.Title) + 4
- }
- if len(cmd.Description) > maxWidth-4 {
- maxWidth = len(cmd.Description) + 4
- }
- }
+ maxWidth := 40
- // Limit height to avoid taking up too much screen space
- maxVisibleCommands := min(10, len(c.commands))
-
- // Build the command list
- commandItems := make([]string, 0, maxVisibleCommands)
- startIdx := 0
-
- // If we have more commands than can be displayed, adjust the start index
- if len(c.commands) > maxVisibleCommands {
- // Center the selected item when possible
- halfVisible := maxVisibleCommands / 2
- if c.selectedIdx >= halfVisible && c.selectedIdx < len(c.commands)-halfVisible {
- startIdx = c.selectedIdx - halfVisible
- } else if c.selectedIdx >= len(c.commands)-halfVisible {
- startIdx = len(c.commands) - maxVisibleCommands
- }
- }
+ commands := c.listView.GetItems()
- endIdx := min(startIdx+maxVisibleCommands, len(c.commands))
-
- for i := startIdx; i < endIdx; i++ {
- cmd := c.commands[i]
- itemStyle := baseStyle.Width(maxWidth)
- descStyle := baseStyle.Width(maxWidth).Foreground(t.TextMuted())
-
- if i == c.selectedIdx {
- itemStyle = itemStyle.
- Background(t.Primary()).
- Foreground(t.Background()).
- Bold(true)
- descStyle = descStyle.
- Background(t.Primary()).
- Foreground(t.Background())
+ for _, cmd := range commands {
+ if len(cmd.Title) > maxWidth-4 {
+ maxWidth = len(cmd.Title) + 4
}
-
- title := itemStyle.Padding(0, 1).Render(cmd.Title)
- description := ""
if cmd.Description != "" {
- description = descStyle.Padding(0, 1).Render(cmd.Description)
- commandItems = append(commandItems, lipgloss.JoinVertical(lipgloss.Left, title, description))
- } else {
- commandItems = append(commandItems, title)
+ if len(cmd.Description) > maxWidth-4 {
+ maxWidth = len(cmd.Description) + 4
+ }
}
}
+ c.listView.SetMaxWidth(maxWidth)
+
title := baseStyle.
Foreground(t.Primary()).
Bold(true).
@@ -192,7 +146,7 @@ func (c *commandDialogCmp) View() string {
lipgloss.Left,
title,
baseStyle.Width(maxWidth).Render(""),
- baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, commandItems...)),
+ baseStyle.Width(maxWidth).Render(c.listView.View()),
baseStyle.Width(maxWidth).Render(""),
)
@@ -209,41 +163,18 @@ func (c *commandDialogCmp) BindingKeys() []key.Binding {
}
func (c *commandDialogCmp) SetCommands(commands []Command) {
- c.commands = commands
-
- // If we have a selected command ID, find its index
- if c.selectedCommandID != "" {
- for i, cmd := range commands {
- if cmd.ID == c.selectedCommandID {
- c.selectedIdx = i
- return
- }
- }
- }
-
- // Default to first command if selected not found
- c.selectedIdx = 0
-}
-
-func (c *commandDialogCmp) SetSelectedCommand(commandID string) {
- c.selectedCommandID = commandID
-
- // Update the selected index if commands are already loaded
- if len(c.commands) > 0 {
- for i, cmd := range c.commands {
- if cmd.ID == commandID {
- c.selectedIdx = i
- return
- }
- }
- }
+ c.listView.SetItems(commands)
}
// NewCommandDialogCmp creates a new command selection dialog
func NewCommandDialogCmp() CommandDialog {
+ listView := utilComponents.NewSimpleList[Command](
+ []Command{},
+ 10,
+ "No commands available",
+ true,
+ )
return &commandDialogCmp{
- commands: []Command{},
- selectedIdx: 0,
- selectedCommandID: "",
+ listView: listView,
}
}
@@ -0,0 +1,264 @@
+package dialog
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/textarea"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/opencode-ai/opencode/internal/logging"
+ utilComponents "github.com/opencode-ai/opencode/internal/tui/components/util"
+ "github.com/opencode-ai/opencode/internal/tui/layout"
+ "github.com/opencode-ai/opencode/internal/tui/styles"
+ "github.com/opencode-ai/opencode/internal/tui/theme"
+ "github.com/opencode-ai/opencode/internal/tui/util"
+)
+
+type CompletionItem struct {
+ title string
+ Title string
+ Value string
+}
+
+type CompletionItemI interface {
+ utilComponents.SimpleListItem
+ GetValue() string
+ DisplayValue() string
+}
+
+func (ci *CompletionItem) Render(selected bool, width int) string {
+ t := theme.CurrentTheme()
+ baseStyle := styles.BaseStyle()
+
+ itemStyle := baseStyle.
+ Width(width).
+ Padding(0, 1)
+
+ if selected {
+ itemStyle = itemStyle.
+ Background(t.Background()).
+ Foreground(t.Primary()).
+ Bold(true)
+ }
+
+ title := itemStyle.Render(
+ ci.GetValue(),
+ )
+
+ return title
+}
+
+func (ci *CompletionItem) DisplayValue() string {
+ return ci.Title
+}
+
+func (ci *CompletionItem) GetValue() string {
+ return ci.Value
+}
+
+func NewCompletionItem(completionItem CompletionItem) CompletionItemI {
+ return &completionItem
+}
+
+type CompletionProvider interface {
+ GetId() string
+ GetEntry() CompletionItemI
+ GetChildEntries(query string) ([]CompletionItemI, error)
+}
+
+type CompletionSelectedMsg struct {
+ SearchString string
+ CompletionValue string
+}
+
+type CompletionDialogCompleteItemMsg struct {
+ Value string
+}
+
+type CompletionDialogCloseMsg struct{}
+
+type CompletionDialog interface {
+ tea.Model
+ layout.Bindings
+ SetWidth(width int)
+}
+
+type completionDialogCmp struct {
+ query string
+ completionProvider CompletionProvider
+ width int
+ height int
+ pseudoSearchTextArea textarea.Model
+ listView utilComponents.SimpleList[CompletionItemI]
+}
+
+type completionDialogKeyMap struct {
+ Complete key.Binding
+ Cancel key.Binding
+}
+
+var completionDialogKeys = completionDialogKeyMap{
+ Complete: key.NewBinding(
+ key.WithKeys("tab", "enter"),
+ ),
+ Cancel: key.NewBinding(
+ key.WithKeys(" ", "esc", "backspace"),
+ ),
+}
+
+func (c *completionDialogCmp) Init() tea.Cmd {
+ return nil
+}
+
+func (c *completionDialogCmp) complete(item CompletionItemI) tea.Cmd {
+ value := c.pseudoSearchTextArea.Value()
+
+ if value == "" {
+ return nil
+ }
+
+ return tea.Batch(
+ util.CmdHandler(CompletionSelectedMsg{
+ SearchString: value,
+ CompletionValue: item.GetValue(),
+ }),
+ c.close(),
+ )
+}
+
+func (c *completionDialogCmp) close() tea.Cmd {
+ c.listView.SetItems([]CompletionItemI{})
+ c.pseudoSearchTextArea.Reset()
+ c.pseudoSearchTextArea.Blur()
+
+ return util.CmdHandler(CompletionDialogCloseMsg{})
+}
+
+func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ if c.pseudoSearchTextArea.Focused() {
+
+ if !key.Matches(msg, completionDialogKeys.Complete) {
+
+ var cmd tea.Cmd
+ c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
+ cmds = append(cmds, cmd)
+
+ var query string
+ query = c.pseudoSearchTextArea.Value()
+ if query != "" {
+ query = query[1:]
+ }
+
+ if query != c.query {
+ logging.Info("Query", query)
+ items, err := c.completionProvider.GetChildEntries(query)
+ if err != nil {
+ logging.Error("Failed to get child entries", err)
+ }
+
+ c.listView.SetItems(items)
+ c.query = query
+ }
+
+ u, cmd := c.listView.Update(msg)
+ c.listView = u.(utilComponents.SimpleList[CompletionItemI])
+
+ cmds = append(cmds, cmd)
+ }
+
+ switch {
+ case key.Matches(msg, completionDialogKeys.Complete):
+ item, i := c.listView.GetSelectedItem()
+ if i == -1 {
+ return c, nil
+ }
+
+ cmd := c.complete(item)
+
+ return c, cmd
+ case key.Matches(msg, completionDialogKeys.Cancel):
+ // Only close on backspace when there are no characters left
+ if msg.String() != "backspace" || len(c.pseudoSearchTextArea.Value()) <= 0 {
+ return c, c.close()
+ }
+ }
+
+ return c, tea.Batch(cmds...)
+ } else {
+ items, err := c.completionProvider.GetChildEntries("")
+ if err != nil {
+ logging.Error("Failed to get child entries", err)
+ }
+
+ c.listView.SetItems(items)
+ c.pseudoSearchTextArea.SetValue(msg.String())
+ return c, c.pseudoSearchTextArea.Focus()
+ }
+ case tea.WindowSizeMsg:
+ c.width = msg.Width
+ c.height = msg.Height
+ }
+
+ return c, tea.Batch(cmds...)
+}
+
+func (c *completionDialogCmp) View() string {
+ t := theme.CurrentTheme()
+ baseStyle := styles.BaseStyle()
+
+ maxWidth := 40
+
+ completions := c.listView.GetItems()
+
+ for _, cmd := range completions {
+ title := cmd.DisplayValue()
+ if len(title) > maxWidth-4 {
+ maxWidth = len(title) + 4
+ }
+ }
+
+ c.listView.SetMaxWidth(maxWidth)
+
+ return baseStyle.Padding(0, 0).
+ Border(lipgloss.NormalBorder()).
+ BorderBottom(false).
+ BorderRight(false).
+ BorderLeft(false).
+ BorderBackground(t.Background()).
+ BorderForeground(t.TextMuted()).
+ Width(c.width).
+ Render(c.listView.View())
+}
+
+func (c *completionDialogCmp) SetWidth(width int) {
+ c.width = width
+}
+
+func (c *completionDialogCmp) BindingKeys() []key.Binding {
+ return layout.KeyMapToSlice(completionDialogKeys)
+}
+
+func NewCompletionDialogCmp(completionProvider CompletionProvider) CompletionDialog {
+ ti := textarea.New()
+
+ items, err := completionProvider.GetChildEntries("")
+ if err != nil {
+ logging.Error("Failed to get child entries", err)
+ }
+
+ li := utilComponents.NewSimpleList(
+ items,
+ 7,
+ "No file matches found",
+ false,
+ )
+
+ return &completionDialogCmp{
+ query: "",
+ completionProvider: completionProvider,
+ pseudoSearchTextArea: ti,
+ listView: li,
+ }
+}
@@ -0,0 +1,159 @@
+package utilComponents
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/opencode-ai/opencode/internal/tui/layout"
+ "github.com/opencode-ai/opencode/internal/tui/styles"
+ "github.com/opencode-ai/opencode/internal/tui/theme"
+)
+
+type SimpleListItem interface {
+ Render(selected bool, width int) string
+}
+
+type SimpleList[T SimpleListItem] interface {
+ tea.Model
+ layout.Bindings
+ SetMaxWidth(maxWidth int)
+ GetSelectedItem() (item T, idx int)
+ SetItems(items []T)
+ GetItems() []T
+}
+
+type simpleListCmp[T SimpleListItem] struct {
+ fallbackMsg string
+ items []T
+ selectedIdx int
+ maxWidth int
+ maxVisibleItems int
+ useAlphaNumericKeys bool
+ width int
+ height int
+}
+
+type simpleListKeyMap struct {
+ Up key.Binding
+ Down key.Binding
+ UpAlpha key.Binding
+ DownAlpha key.Binding
+}
+
+var simpleListKeys = simpleListKeyMap{
+ Up: key.NewBinding(
+ key.WithKeys("up"),
+ key.WithHelp("↑", "previous list item"),
+ ),
+ Down: key.NewBinding(
+ key.WithKeys("down"),
+ key.WithHelp("↓", "next list item"),
+ ),
+ UpAlpha: key.NewBinding(
+ key.WithKeys("k"),
+ key.WithHelp("k", "previous list item"),
+ ),
+ DownAlpha: key.NewBinding(
+ key.WithKeys("j"),
+ key.WithHelp("j", "next list item"),
+ ),
+}
+
+func (c *simpleListCmp[T]) Init() tea.Cmd {
+ return nil
+}
+
+func (c *simpleListCmp[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, simpleListKeys.Up) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.UpAlpha)):
+ if c.selectedIdx > 0 {
+ c.selectedIdx--
+ }
+ return c, nil
+ case key.Matches(msg, simpleListKeys.Down) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.DownAlpha)):
+ if c.selectedIdx < len(c.items)-1 {
+ c.selectedIdx++
+ }
+ return c, nil
+ }
+ }
+
+ return c, nil
+}
+
+func (c *simpleListCmp[T]) BindingKeys() []key.Binding {
+ return layout.KeyMapToSlice(simpleListKeys)
+}
+
+func (c *simpleListCmp[T]) GetSelectedItem() (T, int) {
+ if len(c.items) > 0 {
+ return c.items[c.selectedIdx], c.selectedIdx
+ }
+
+ var zero T
+ return zero, -1
+}
+
+func (c *simpleListCmp[T]) SetItems(items []T) {
+ c.selectedIdx = 0
+ c.items = items
+}
+
+func (c *simpleListCmp[T]) GetItems() []T {
+ return c.items
+}
+
+func (c *simpleListCmp[T]) SetMaxWidth(width int) {
+ c.maxWidth = width
+}
+
+func (c *simpleListCmp[T]) View() string {
+ t := theme.CurrentTheme()
+ baseStyle := styles.BaseStyle()
+
+ items := c.items
+ maxWidth := c.maxWidth
+ maxVisibleItems := min(c.maxVisibleItems, len(items))
+ startIdx := 0
+
+ if len(items) <= 0 {
+ return baseStyle.
+ Background(t.Background()).
+ Padding(0, 1).
+ Width(maxWidth).
+ Render(c.fallbackMsg)
+ }
+
+ if len(items) > maxVisibleItems {
+ halfVisible := maxVisibleItems / 2
+ if c.selectedIdx >= halfVisible && c.selectedIdx < len(items)-halfVisible {
+ startIdx = c.selectedIdx - halfVisible
+ } else if c.selectedIdx >= len(items)-halfVisible {
+ startIdx = len(items) - maxVisibleItems
+ }
+ }
+
+ endIdx := min(startIdx+maxVisibleItems, len(items))
+
+ listItems := make([]string, 0, maxVisibleItems)
+
+ for i := startIdx; i < endIdx; i++ {
+ item := items[i]
+ title := item.Render(i == c.selectedIdx, maxWidth)
+ listItems = append(listItems, title)
+ }
+
+ return lipgloss.JoinVertical(lipgloss.Left, listItems...)
+}
+
+func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) SimpleList[T] {
+ return &simpleListCmp[T]{
+ fallbackMsg: fallbackMsg,
+ items: items,
+ maxVisibleItems: maxVisibleItems,
+ useAlphaNumericKeys: useAlphaNumericKeys,
+ selectedIdx: 0,
+ }
+}
@@ -6,7 +6,9 @@ import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
"github.com/opencode-ai/opencode/internal/app"
+ "github.com/opencode-ai/opencode/internal/completions"
"github.com/opencode-ai/opencode/internal/message"
"github.com/opencode-ai/opencode/internal/session"
"github.com/opencode-ai/opencode/internal/tui/components/chat"
@@ -18,19 +20,26 @@ import (
var ChatPage PageID = "chat"
type chatPage struct {
- app *app.App
- editor layout.Container
- messages layout.Container
- layout layout.SplitPaneLayout
- session session.Session
+ app *app.App
+ editor layout.Container
+ messages layout.Container
+ layout layout.SplitPaneLayout
+ session session.Session
+ completionDialog dialog.CompletionDialog
+ showCompletionDialog bool
}
type ChatKeyMap struct {
- NewSession key.Binding
- Cancel key.Binding
+ ShowCompletionDialog key.Binding
+ NewSession key.Binding
+ Cancel key.Binding
}
var keyMap = ChatKeyMap{
+ ShowCompletionDialog: key.NewBinding(
+ key.WithKeys("@"),
+ key.WithHelp("@", "Complete"),
+ ),
NewSession: key.NewBinding(
key.WithKeys("ctrl+n"),
key.WithHelp("ctrl+n", "new session"),
@@ -44,6 +53,7 @@ var keyMap = ChatKeyMap{
func (p *chatPage) Init() tea.Cmd {
cmds := []tea.Cmd{
p.layout.Init(),
+ p.completionDialog.Init(),
}
return tea.Batch(cmds...)
}
@@ -54,6 +64,8 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
cmd := p.layout.SetSize(msg.Width, msg.Height)
cmds = append(cmds, cmd)
+ case dialog.CompletionDialogCloseMsg:
+ p.showCompletionDialog = false
case chat.SendMsg:
cmd := p.sendMessage(msg.Text, msg.Attachments)
if cmd != nil {
@@ -90,6 +102,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
p.session = msg
case tea.KeyMsg:
switch {
+ case key.Matches(msg, keyMap.ShowCompletionDialog):
+ p.showCompletionDialog = true
+ // Continue sending keys to layout->chat
case key.Matches(msg, keyMap.NewSession):
p.session = session.Session{}
return p, tea.Batch(
@@ -105,9 +120,23 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
}
+ if p.showCompletionDialog {
+ context, contextCmd := p.completionDialog.Update(msg)
+ p.completionDialog = context.(dialog.CompletionDialog)
+ cmds = append(cmds, contextCmd)
+
+ // Doesn't forward event if enter key is pressed
+ if keyMsg, ok := msg.(tea.KeyMsg); ok {
+ if keyMsg.String() == "enter" {
+ return p, tea.Batch(cmds...)
+ }
+ }
+ }
+
u, cmd := p.layout.Update(msg)
cmds = append(cmds, cmd)
p.layout = u.(layout.SplitPaneLayout)
+
return p, tea.Batch(cmds...)
}
@@ -155,7 +184,25 @@ func (p *chatPage) GetSize() (int, int) {
}
func (p *chatPage) View() string {
- return p.layout.View()
+ layoutView := p.layout.View()
+
+ if p.showCompletionDialog {
+ _, layoutHeight := p.layout.GetSize()
+ editorWidth, editorHeight := p.editor.GetSize()
+
+ p.completionDialog.SetWidth(editorWidth)
+ overlay := p.completionDialog.View()
+
+ layoutView = layout.PlaceOverlay(
+ 0,
+ layoutHeight-editorHeight-lipgloss.Height(overlay),
+ overlay,
+ layoutView,
+ false,
+ )
+ }
+
+ return layoutView
}
func (p *chatPage) BindingKeys() []key.Binding {
@@ -166,6 +213,9 @@ func (p *chatPage) BindingKeys() []key.Binding {
}
func NewChatPage(app *app.App) tea.Model {
+ cg := completions.NewFileAndFolderContextGroup()
+ completionDialog := dialog.NewCompletionDialogCmp(cg)
+
messagesContainer := layout.NewContainer(
chat.NewMessagesCmp(app),
layout.WithPadding(1, 1, 0, 1),
@@ -175,9 +225,10 @@ func NewChatPage(app *app.App) tea.Model {
layout.WithBorder(true, false, false, false),
)
return &chatPage{
- app: app,
- editor: editorContainer,
- messages: messagesContainer,
+ app: app,
+ editor: editorContainer,
+ messages: messagesContainer,
+ completionDialog: completionDialog,
layout: layout.NewSplitPane(
layout.WithLeftPanel(messagesContainer),
layout.WithBottomPanel(editorContainer),