Skip to content

Add completion menu for file paths #145

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
191 changes: 191 additions & 0 deletions internal/completions/files-folders.go
Original file line number Diff line number Diff line change
@@ -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",
}
}
163 changes: 163 additions & 0 deletions internal/fileutil/fileutil.go
Original file line number Diff line number Diff line change
@@ -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,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added opencode to common ignored dirs

"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
}
161 changes: 19 additions & 142 deletions internal/llm/tools/glob.go
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 2 additions & 1 deletion internal/llm/tools/grep.go
Original file line number Diff line number Diff line change
@@ -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
}

8 changes: 7 additions & 1 deletion internal/tui/components/chat/editor.go
Original file line number Diff line number Diff line change
@@ -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] == '\\' {
191 changes: 61 additions & 130 deletions internal/tui/components/dialog/commands.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
264 changes: 264 additions & 0 deletions internal/tui/components/dialog/complete.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
159 changes: 159 additions & 0 deletions internal/tui/components/util/simple-list.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
73 changes: 62 additions & 11 deletions internal/tui/page/chat.go
Original file line number Diff line number Diff line change
@@ -5,7 +5,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"
@@ -17,19 +19,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"),
@@ -43,6 +52,7 @@ var keyMap = ChatKeyMap{
func (p *chatPage) Init() tea.Cmd {
cmds := []tea.Cmd{
p.layout.Init(),
p.completionDialog.Init(),
}
return tea.Batch(cmds...)
}
@@ -53,6 +63,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 {
@@ -78,6 +90,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(
@@ -93,9 +108,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...)
}

@@ -143,7 +172,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 {
@@ -154,6 +201,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),
@@ -163,9 +213,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),