Compare commits

..
9 Commits
Author SHA1 Message Date
Adictya a203fb8ccc fix(complete-module): change completion start key to slash
release / goreleaser (push) Has been cancelled
2025-05-15 08:29:54 -05:00
Adictya acc084c9ea chore(complete-module): lint 2025-05-15 08:29:54 -05:00
Adictya 3ee213081e fix(complete-module): logging 2025-05-15 08:29:54 -05:00
Adictya 15bf40bc10 feat(complete-module): add completions logic, dialog and providers 2025-05-15 08:29:54 -05:00
adamdottv a33e3e25b6 chore: cleanup dead code 2025-05-15 07:19:17 -05:00
adamdottv 658faab2bf chore: update icon in readme 2025-05-15 07:03:58 -05:00
rekram1-node 797045ee29 feat: add configuration persistence for model selections 2025-05-14 16:32:05 -05:00
adamdottv c8f8d67a88 feat: codeAction tool
release / goreleaser (push) Has been cancelled
2025-05-14 14:57:47 -05:00
Dax Raad 182e32e4f7 add screenshot 2025-05-14 15:50:25 -04:00
17 changed files with 1339 additions and 349 deletions
+3 -1
View File
@@ -1,4 +1,6 @@
# OpenCode
# OpenCode
![OpenCode Terminal UI](screenshot.png)
> **⚠️ Early Development Notice:** This project is in early development and is not yet ready for production use. Features may change, break, or be incomplete. Use at your own risk.
+1
View File
@@ -19,6 +19,7 @@ require (
github.com/fsnotify/fsnotify v1.8.0
github.com/go-logfmt/logfmt v0.6.0
github.com/google/uuid v1.6.0
github.com/lithammer/fuzzysearch v1.1.8
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
github.com/mark3labs/mcp-go v0.17.0
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
+2
View File
@@ -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
View File
@@ -0,0 +1,191 @@
package completions
import (
"bytes"
"fmt"
"os/exec"
"path/filepath"
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/sst/opencode/internal/fileutil"
"github.com/sst/opencode/internal/status"
"github.com/sst/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 {
status.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 {
status.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 {
status.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 {
status.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",
}
}
+58 -51
View File
@@ -56,7 +56,7 @@ type Provider struct {
// Data defines storage configuration.
type Data struct {
Directory string `json:"directory"`
Directory string `json:"directory,omitempty"`
}
// LSPConfig defines configuration for Language Server Protocol integration.
@@ -80,7 +80,7 @@ type Config struct {
MCPServers map[string]MCPServer `json:"mcpServers,omitempty"`
Providers map[models.ModelProvider]Provider `json:"providers,omitempty"`
LSP map[string]LSPConfig `json:"lsp,omitempty"`
Agents map[AgentName]Agent `json:"agents"`
Agents map[AgentName]Agent `json:"agents,omitempty"`
Debug bool `json:"debug,omitempty"`
DebugLSP bool `json:"debugLSP,omitempty"`
ContextPaths []string `json:"contextPaths,omitempty"`
@@ -704,6 +704,52 @@ func GetUsername() (string, error) {
return currentUser.Username, nil
}
func updateCfgFile(updateCfg func(config *Config)) error {
if cfg == nil {
return fmt.Errorf("config not loaded")
}
// Get the config file path
configFile := viper.ConfigFileUsed()
var configData []byte
if configFile == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home directory: %w", err)
}
configFile = filepath.Join(homeDir, fmt.Sprintf(".%s.json", appName))
slog.Info("config file not found, creating new one", "path", configFile)
configData = []byte(`{}`)
} else {
// Read the existing config file
data, err := os.ReadFile(configFile)
if err != nil {
return fmt.Errorf("failed to read config file: %w", err)
}
configData = data
}
// Parse the JSON
var userCfg *Config
if err := json.Unmarshal(configData, &userCfg); err != nil {
return fmt.Errorf("failed to parse config file: %w", err)
}
updateCfg(userCfg)
// Write the updated config back to file
updatedData, err := json.MarshalIndent(userCfg, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
if err := os.WriteFile(configFile, updatedData, 0o644); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
return nil
}
func UpdateAgentModel(agentName AgentName, modelID models.ModelID) error {
if cfg == nil {
panic("config not loaded")
@@ -734,7 +780,12 @@ func UpdateAgentModel(agentName AgentName, modelID models.ModelID) error {
return fmt.Errorf("failed to update agent model: %w", err)
}
return nil
return updateCfgFile(func(config *Config) {
if config.Agents == nil {
config.Agents = make(map[AgentName]Agent)
}
config.Agents[agentName] = newAgentCfg
})
}
// UpdateTheme updates the theme in the configuration and writes it to the config file.
@@ -746,52 +797,8 @@ func UpdateTheme(themeName string) error {
// Update the in-memory config
cfg.TUI.Theme = themeName
// Get the config file path
configFile := viper.ConfigFileUsed()
var configData []byte
if configFile == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home directory: %w", err)
}
configFile = filepath.Join(homeDir, fmt.Sprintf(".%s.json", appName))
slog.Info("config file not found, creating new one", "path", configFile)
configData = []byte(`{}`)
} else {
// Read the existing config file
data, err := os.ReadFile(configFile)
if err != nil {
return fmt.Errorf("failed to read config file: %w", err)
}
configData = data
}
// Parse the JSON
var configMap map[string]any
if err := json.Unmarshal(configData, &configMap); err != nil {
return fmt.Errorf("failed to parse config file: %w", err)
}
// Update just the theme value
tuiConfig, ok := configMap["tui"].(map[string]any)
if !ok {
// TUI config doesn't exist yet, create it
configMap["tui"] = map[string]any{"theme": themeName}
} else {
// Update existing TUI config
tuiConfig["theme"] = themeName
configMap["tui"] = tuiConfig
}
// Write the updated config back to file
updatedData, err := json.MarshalIndent(configMap, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
if err := os.WriteFile(configFile, updatedData, 0o644); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
return nil
// Update the file config
return updateCfgFile(func(config *Config) {
config.TUI.Theme = themeName
})
}
+163
View 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/sst/opencode/internal/status"
)
var (
rgPath string
fzfPath string
)
func init() {
var err error
rgPath, err = exec.LookPath("rg")
if err != nil {
status.Warn("Ripgrep (rg) not found in $PATH. Some features might be limited or slower.")
rgPath = ""
}
fzfPath, err = exec.LookPath("fzf")
if err != nil {
status.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
}
+1
View File
@@ -37,6 +37,7 @@ func PrimaryAgentTools(
tools.NewReferencesTool(lspClients),
tools.NewDocSymbolsTool(lspClients),
tools.NewWorkspaceSymbolsTool(lspClients),
tools.NewCodeActionTool(lspClients),
NewAgentTool(sessions, messages, lspClients),
}, mcpTools...,
)
+19 -142
View File
@@ -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/sst/opencode/internal/config"
"github.com/sst/opencode/internal/fileutil"
"github.com/sst/opencode/internal/status"
)
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
}
status.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
}
+2 -1
View File
@@ -15,6 +15,7 @@ import (
"time"
"github.com/sst/opencode/internal/config"
"github.com/sst/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
}
+350
View File
@@ -0,0 +1,350 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/lsp/protocol"
"github.com/sst/opencode/internal/lsp/util"
)
type CodeActionParams struct {
FilePath string `json:"file_path"`
Line int `json:"line"`
Column int `json:"column"`
EndLine int `json:"end_line,omitempty"`
EndColumn int `json:"end_column,omitempty"`
ActionID int `json:"action_id,omitempty"`
LspName string `json:"lsp_name,omitempty"`
}
type codeActionTool struct {
lspClients map[string]*lsp.Client
}
const (
CodeActionToolName = "codeAction"
codeActionDescription = `Get available code actions at a specific position or range in a file.
WHEN TO USE THIS TOOL:
- Use when you need to find available fixes or refactorings for code issues
- Helpful for resolving errors, warnings, or improving code quality
- Great for discovering automated code transformations
HOW TO USE:
- Provide the path to the file containing the code
- Specify the line number (1-based) where the action should be applied
- Specify the column number (1-based) where the action should be applied
- Optionally specify end_line and end_column to define a range
- Results show available code actions with their titles and kinds
TO EXECUTE A CODE ACTION:
- After getting the list of available actions, call the tool again with the same parameters
- Add action_id parameter with the number of the action you want to execute (e.g., 1 for the first action)
- Add lsp_name parameter with the name of the LSP server that provided the action
FEATURES:
- Finds quick fixes for errors and warnings
- Discovers available refactorings
- Shows code organization actions
- Returns detailed information about each action
- Can execute selected code actions
LIMITATIONS:
- Requires a functioning LSP server for the file type
- May not work for all code issues depending on LSP capabilities
- Results depend on the accuracy of the LSP server
TIPS:
- Use in conjunction with Diagnostics tool to find issues that can be fixed
- First call without action_id to see available actions, then call again with action_id to execute
`
)
func NewCodeActionTool(lspClients map[string]*lsp.Client) BaseTool {
return &codeActionTool{
lspClients,
}
}
func (b *codeActionTool) Info() ToolInfo {
return ToolInfo{
Name: CodeActionToolName,
Description: codeActionDescription,
Parameters: map[string]any{
"file_path": map[string]any{
"type": "string",
"description": "The path to the file containing the code",
},
"line": map[string]any{
"type": "integer",
"description": "The line number (1-based) where the action should be applied",
},
"column": map[string]any{
"type": "integer",
"description": "The column number (1-based) where the action should be applied",
},
"end_line": map[string]any{
"type": "integer",
"description": "The ending line number (1-based) for a range (optional)",
},
"end_column": map[string]any{
"type": "integer",
"description": "The ending column number (1-based) for a range (optional)",
},
"action_id": map[string]any{
"type": "integer",
"description": "The ID of the code action to execute (optional)",
},
"lsp_name": map[string]any{
"type": "string",
"description": "The name of the LSP server that provided the action (optional)",
},
},
Required: []string{"file_path", "line", "column"},
}
}
func (b *codeActionTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
var params CodeActionParams
if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
}
lsps := b.lspClients
if len(lsps) == 0 {
return NewTextResponse("\nLSP clients are still initializing. Code actions will be available once they're ready.\n"), nil
}
// Ensure file is open in LSP
notifyLspOpenFile(ctx, params.FilePath, lsps)
// Convert 1-based line/column to 0-based for LSP protocol
line := max(0, params.Line-1)
column := max(0, params.Column-1)
// Handle optional end line/column
endLine := line
endColumn := column
if params.EndLine > 0 {
endLine = max(0, params.EndLine-1)
}
if params.EndColumn > 0 {
endColumn = max(0, params.EndColumn-1)
}
// Check if we're executing a specific action
if params.ActionID > 0 && params.LspName != "" {
return executeCodeAction(ctx, params.FilePath, line, column, endLine, endColumn, params.ActionID, params.LspName, lsps)
}
// Otherwise, just list available actions
output := getCodeActions(ctx, params.FilePath, line, column, endLine, endColumn, lsps)
return NewTextResponse(output), nil
}
func getCodeActions(ctx context.Context, filePath string, line, column, endLine, endColumn int, lsps map[string]*lsp.Client) string {
var results []string
for lspName, client := range lsps {
// Create code action params
uri := fmt.Sprintf("file://%s", filePath)
codeActionParams := protocol.CodeActionParams{
TextDocument: protocol.TextDocumentIdentifier{
URI: protocol.DocumentUri(uri),
},
Range: protocol.Range{
Start: protocol.Position{
Line: uint32(line),
Character: uint32(column),
},
End: protocol.Position{
Line: uint32(endLine),
Character: uint32(endColumn),
},
},
Context: protocol.CodeActionContext{
// Request all kinds of code actions
Only: []protocol.CodeActionKind{
protocol.QuickFix,
protocol.Refactor,
protocol.RefactorExtract,
protocol.RefactorInline,
protocol.RefactorRewrite,
protocol.Source,
protocol.SourceOrganizeImports,
protocol.SourceFixAll,
},
},
}
// Get code actions
codeActions, err := client.CodeAction(ctx, codeActionParams)
if err != nil {
results = append(results, fmt.Sprintf("Error from %s: %s", lspName, err))
continue
}
if len(codeActions) == 0 {
results = append(results, fmt.Sprintf("No code actions found by %s", lspName))
continue
}
// Format the code actions
results = append(results, fmt.Sprintf("Code actions found by %s:", lspName))
for i, action := range codeActions {
actionInfo := formatCodeAction(action, i+1)
results = append(results, actionInfo)
}
}
if len(results) == 0 {
return "No code actions found at the specified position."
}
return strings.Join(results, "\n")
}
func formatCodeAction(action protocol.Or_Result_textDocument_codeAction_Item0_Elem, index int) string {
switch v := action.Value.(type) {
case protocol.CodeAction:
kind := "Unknown"
if v.Kind != "" {
kind = string(v.Kind)
}
var details []string
// Add edit information if available
if v.Edit != nil {
numChanges := 0
if v.Edit.Changes != nil {
numChanges = len(v.Edit.Changes)
}
if v.Edit.DocumentChanges != nil {
numChanges = len(v.Edit.DocumentChanges)
}
details = append(details, fmt.Sprintf("Edits: %d changes", numChanges))
}
// Add command information if available
if v.Command != nil {
details = append(details, fmt.Sprintf("Command: %s", v.Command.Title))
}
// Add diagnostics information if available
if v.Diagnostics != nil && len(v.Diagnostics) > 0 {
details = append(details, fmt.Sprintf("Fixes: %d diagnostics", len(v.Diagnostics)))
}
detailsStr := ""
if len(details) > 0 {
detailsStr = " (" + strings.Join(details, ", ") + ")"
}
return fmt.Sprintf(" %d. %s [%s]%s", index, v.Title, kind, detailsStr)
case protocol.Command:
return fmt.Sprintf(" %d. %s [Command]", index, v.Title)
}
return fmt.Sprintf(" %d. Unknown code action type", index)
}
func executeCodeAction(ctx context.Context, filePath string, line, column, endLine, endColumn, actionID int, lspName string, lsps map[string]*lsp.Client) (ToolResponse, error) {
client, ok := lsps[lspName]
if !ok {
return NewTextErrorResponse(fmt.Sprintf("LSP server '%s' not found", lspName)), nil
}
// Create code action params
uri := fmt.Sprintf("file://%s", filePath)
codeActionParams := protocol.CodeActionParams{
TextDocument: protocol.TextDocumentIdentifier{
URI: protocol.DocumentUri(uri),
},
Range: protocol.Range{
Start: protocol.Position{
Line: uint32(line),
Character: uint32(column),
},
End: protocol.Position{
Line: uint32(endLine),
Character: uint32(endColumn),
},
},
Context: protocol.CodeActionContext{
// Request all kinds of code actions
Only: []protocol.CodeActionKind{
protocol.QuickFix,
protocol.Refactor,
protocol.RefactorExtract,
protocol.RefactorInline,
protocol.RefactorRewrite,
protocol.Source,
protocol.SourceOrganizeImports,
protocol.SourceFixAll,
},
},
}
// Get code actions
codeActions, err := client.CodeAction(ctx, codeActionParams)
if err != nil {
return NewTextErrorResponse(fmt.Sprintf("Error getting code actions: %s", err)), nil
}
if len(codeActions) == 0 {
return NewTextErrorResponse("No code actions found"), nil
}
// Check if the requested action ID is valid
if actionID < 1 || actionID > len(codeActions) {
return NewTextErrorResponse(fmt.Sprintf("Invalid action ID: %d. Available actions: 1-%d", actionID, len(codeActions))), nil
}
// Get the selected action (adjust for 0-based index)
selectedAction := codeActions[actionID-1]
// Execute the action based on its type
switch v := selectedAction.Value.(type) {
case protocol.CodeAction:
// Apply workspace edit if available
if v.Edit != nil {
err := util.ApplyWorkspaceEdit(*v.Edit)
if err != nil {
return NewTextErrorResponse(fmt.Sprintf("Error applying edit: %s", err)), nil
}
}
// Execute command if available
if v.Command != nil {
_, err := client.ExecuteCommand(ctx, protocol.ExecuteCommandParams{
Command: v.Command.Command,
Arguments: v.Command.Arguments,
})
if err != nil {
return NewTextErrorResponse(fmt.Sprintf("Error executing command: %s", err)), nil
}
}
return NewTextResponse(fmt.Sprintf("Successfully executed code action: %s", v.Title)), nil
case protocol.Command:
// Execute the command
_, err := client.ExecuteCommand(ctx, protocol.ExecuteCommandParams{
Command: v.Command,
Arguments: v.Arguments,
})
if err != nil {
return NewTextErrorResponse(fmt.Sprintf("Error executing command: %s", err)), nil
}
return NewTextResponse(fmt.Sprintf("Successfully executed command: %s", v.Title)), nil
}
return NewTextErrorResponse("Unknown code action type"), nil
}
+6
View File
@@ -5,6 +5,7 @@ import (
"os"
"os/exec"
"slices"
"strings"
"unicode"
"github.com/charmbracelet/bubbles/key"
@@ -148,6 +149,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 dialog.AttachmentAddedMsg:
if len(m.attachments) >= maxAttachments {
+61 -130
View File
@@ -4,6 +4,7 @@ import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
utilComponents "github.com/sst/opencode/internal/tui/components/util"
"github.com/sst/opencode/internal/tui/layout"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/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")
}
maxWidth := 40
// 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
commands := c.listView.GetItems()
for _, cmd := range commands {
if len(cmd.Title) > maxWidth-4 {
maxWidth = len(cmd.Title) + 4
}
if len(cmd.Description) > maxWidth-4 {
maxWidth = len(cmd.Description) + 4
}
}
// 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
}
}
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())
}
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,
}
}
+263
View File
@@ -0,0 +1,263 @@
package dialog
import (
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/status"
utilComponents "github.com/sst/opencode/internal/tui/components/util"
"github.com/sst/opencode/internal/tui/layout"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/sst/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 {
items, err := c.completionProvider.GetChildEntries(query)
if err != nil {
status.Error(err.Error())
}
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 {
status.Error(err.Error())
}
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 {
status.Error(err.Error())
}
li := utilComponents.NewSimpleList(
items,
7,
"No file matches found",
false,
)
return &completionDialogCmp{
query: "",
completionProvider: completionProvider,
pseudoSearchTextArea: ti,
listView: li,
}
}
+159
View File
@@ -0,0 +1,159 @@
package utilComponents
import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/tui/layout"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/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,
}
}
+60 -11
View File
@@ -6,7 +6,9 @@ import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/completions"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/session"
"github.com/sst/opencode/internal/status"
@@ -20,16 +22,19 @@ import (
var ChatPage PageID = "chat"
type chatPage struct {
app *app.App
editor layout.Container
messages layout.Container
layout layout.SplitPaneLayout
app *app.App
editor layout.Container
messages layout.Container
layout layout.SplitPaneLayout
completionDialog dialog.CompletionDialog
showCompletionDialog bool
}
type ChatKeyMap struct {
NewSession key.Binding
Cancel key.Binding
ToggleTools key.Binding
NewSession key.Binding
Cancel key.Binding
ToggleTools key.Binding
ShowCompletionDialog key.Binding
}
var keyMap = ChatKeyMap{
@@ -45,12 +50,17 @@ var keyMap = ChatKeyMap{
key.WithKeys("ctrl+h"),
key.WithHelp("ctrl+h", "toggle tools"),
),
ShowCompletionDialog: key.NewBinding(
key.WithKeys("/"),
key.WithHelp("/", "Complete"),
),
}
func (p *chatPage) Init() tea.Cmd {
cmds := []tea.Cmd{
p.layout.Init(),
}
cmds = append(cmds, p.completionDialog.Init())
return tea.Batch(cmds...)
}
@@ -99,8 +109,13 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}(p.app.CurrentSession.ID)
return p, nil
case dialog.CompletionDialogCloseMsg:
p.showCompletionDialog = false
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.app.CurrentSession = &session.Session{}
return p, tea.Batch(
@@ -118,6 +133,19 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return p, util.CmdHandler(chat.ToggleToolMessagesMsg{})
}
}
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)
@@ -171,7 +199,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 {
@@ -182,6 +228,8 @@ 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),
@@ -191,9 +239,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),
-13
View File
@@ -10,16 +10,3 @@ const (
SpinnerIcon string = "⟳"
DocumentIcon string = "🖼"
)
// CircledDigit returns the Unicode circled digit/number for 020.
// outofrange → "".
func CircledDigit(n int) string {
switch {
case n == 0:
return "\u24EA" // ⓪
case 1 <= n && n <= 20:
return string(rune(0x2460 + n - 1)) // ①–⑳
default:
return ""
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB