mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-05-02 23:04:07 +08:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9cca48d08 | ||
|
|
3944930fc0 | ||
|
|
825c0b64af | ||
|
|
d7af7dd3fe | ||
|
|
b112216241 | ||
|
|
87237b6462 | ||
|
|
5f5f9dad87 | ||
|
|
aa8b3ce1ee | ||
|
|
a65e593ab4 | ||
|
|
5d9058eb74 | ||
|
|
a850320fad | ||
|
|
ddbb217d0d | ||
|
|
ab150be7c3 | ||
|
|
a203fb8ccc | ||
|
|
acc084c9ea | ||
|
|
3ee213081e | ||
|
|
15bf40bc10 | ||
|
|
a33e3e25b6 | ||
|
|
658faab2bf | ||
|
|
797045ee29 | ||
|
|
c8f8d67a88 | ||
|
|
182e32e4f7 |
27
README.md
27
README.md
@@ -1,4 +1,6 @@
|
||||
# ⌬ OpenCode
|
||||
# ⓒ OpenCode
|
||||
|
||||

|
||||
|
||||
> **⚠️ 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.
|
||||
|
||||
@@ -19,6 +21,7 @@ OpenCode is a Go-based CLI application that brings AI assistance to your termina
|
||||
- **LSP Integration**: Language Server Protocol support for code intelligence
|
||||
- **File Change Tracking**: Track and visualize file changes during sessions
|
||||
- **External Editor Support**: Open your preferred editor for composing messages
|
||||
- **Named Arguments for Custom Commands**: Create powerful custom commands with multiple named placeholders
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -71,6 +74,8 @@ You can configure OpenCode using environment variables:
|
||||
| `ANTHROPIC_API_KEY` | For Claude models |
|
||||
| `OPENAI_API_KEY` | For OpenAI models |
|
||||
| `GEMINI_API_KEY` | For Google Gemini models |
|
||||
| `VERTEXAI_PROJECT` | For Google Cloud VertexAI (Gemini) |
|
||||
| `VERTEXAI_LOCATION` | For Google Cloud VertexAI (Gemini) |
|
||||
| `GROQ_API_KEY` | For Groq models |
|
||||
| `AWS_ACCESS_KEY_ID` | For AWS Bedrock (Claude) |
|
||||
| `AWS_SECRET_ACCESS_KEY` | For AWS Bedrock (Claude) |
|
||||
@@ -186,6 +191,11 @@ OpenCode supports a variety of AI models from different providers:
|
||||
- O3 family (o3, o3-mini)
|
||||
- O4 Mini
|
||||
|
||||
### Google Cloud VertexAI
|
||||
|
||||
- Gemini 2.5
|
||||
- Gemini 2.5 Flash
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
@@ -426,13 +436,22 @@ This creates a command called `user:prime-context`.
|
||||
|
||||
### Command Arguments
|
||||
|
||||
You can create commands that accept arguments by including the `$ARGUMENTS` placeholder in your command file:
|
||||
OpenCode supports named arguments in custom commands using placeholders in the format `$NAME` (where NAME consists of uppercase letters, numbers, and underscores, and must start with a letter).
|
||||
|
||||
For example:
|
||||
|
||||
```markdown
|
||||
RUN git show $ARGUMENTS
|
||||
# Fetch Context for Issue $ISSUE_NUMBER
|
||||
|
||||
RUN gh issue view $ISSUE_NUMBER --json title,body,comments
|
||||
RUN git grep --author="$AUTHOR_NAME" -n .
|
||||
RUN grep -R "$SEARCH_PATTERN" $DIRECTORY
|
||||
```
|
||||
|
||||
When you run this command, OpenCode will prompt you to enter the text that should replace `$ARGUMENTS`.
|
||||
When you run a command with arguments, OpenCode will prompt you to enter values for each unique placeholder. Named arguments provide several benefits:
|
||||
- Clear identification of what each argument represents
|
||||
- Ability to use the same argument multiple times
|
||||
- Better organization for commands with multiple inputs
|
||||
|
||||
### Organizing Commands
|
||||
|
||||
|
||||
@@ -227,6 +227,7 @@ func generateSchema() map[string]any {
|
||||
string(models.ProviderOpenRouter),
|
||||
string(models.ProviderBedrock),
|
||||
string(models.ProviderAzure),
|
||||
string(models.ProviderVertexAI),
|
||||
}
|
||||
|
||||
providerSchema["additionalProperties"].(map[string]any)["properties"].(map[string]any)["provider"] = map[string]any{
|
||||
|
||||
1
go.mod
1
go.mod
@@ -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
go.sum
2
go.sum
@@ -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=
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/fileutil"
|
||||
"github.com/sst/opencode/internal/history"
|
||||
"github.com/sst/opencode/internal/llm/agent"
|
||||
"github.com/sst/opencode/internal/logging"
|
||||
@@ -72,6 +73,7 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
|
||||
slog.Error("Failed to initialize status service", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
fileutil.Init()
|
||||
|
||||
app := &App{
|
||||
CurrentSession: &session.Session{},
|
||||
|
||||
191
internal/completions/files-folders.go
Normal file
191
internal/completions/files-folders.go
Normal 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",
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
@@ -235,6 +235,7 @@ func setProviderDefaults() {
|
||||
// 5. OpenRouter
|
||||
// 6. AWS Bedrock
|
||||
// 7. Azure
|
||||
// 8. Google Cloud VertexAI
|
||||
|
||||
// Anthropic configuration
|
||||
if key := viper.GetString("providers.anthropic.apiKey"); strings.TrimSpace(key) != "" {
|
||||
@@ -299,6 +300,15 @@ func setProviderDefaults() {
|
||||
viper.SetDefault("agents.title.model", models.AzureGPT41Mini)
|
||||
return
|
||||
}
|
||||
|
||||
// Google Cloud VertexAI configuration
|
||||
if hasVertexAICredentials() {
|
||||
viper.SetDefault("agents.coder.model", models.VertexAIGemini25)
|
||||
viper.SetDefault("agents.summarizer.model", models.VertexAIGemini25)
|
||||
viper.SetDefault("agents.task.model", models.VertexAIGemini25Flash)
|
||||
viper.SetDefault("agents.title.model", models.VertexAIGemini25Flash)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// hasAWSCredentials checks if AWS credentials are available in the environment.
|
||||
@@ -327,6 +337,19 @@ func hasAWSCredentials() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// hasVertexAICredentials checks if VertexAI credentials are available in the environment.
|
||||
func hasVertexAICredentials() bool {
|
||||
// Check for explicit VertexAI parameters
|
||||
if os.Getenv("VERTEXAI_PROJECT") != "" && os.Getenv("VERTEXAI_LOCATION") != "" {
|
||||
return true
|
||||
}
|
||||
// Check for Google Cloud project and location
|
||||
if os.Getenv("GOOGLE_CLOUD_PROJECT") != "" && (os.Getenv("GOOGLE_CLOUD_REGION") != "" || os.Getenv("GOOGLE_CLOUD_LOCATION") != "") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// readConfig handles the result of reading a configuration file.
|
||||
func readConfig(err error) error {
|
||||
if err == nil {
|
||||
@@ -549,6 +572,10 @@ func getProviderAPIKey(provider models.ModelProvider) string {
|
||||
if hasAWSCredentials() {
|
||||
return "aws-credentials-available"
|
||||
}
|
||||
case models.ProviderVertexAI:
|
||||
if hasVertexAICredentials() {
|
||||
return "vertex-ai-credentials-available"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -669,6 +696,24 @@ func setDefaultModelForAgent(agent AgentName) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
if hasVertexAICredentials() {
|
||||
var model models.ModelID
|
||||
maxTokens := int64(5000)
|
||||
|
||||
if agent == AgentTitle {
|
||||
model = models.VertexAIGemini25Flash
|
||||
maxTokens = 80
|
||||
} else {
|
||||
model = models.VertexAIGemini25
|
||||
}
|
||||
|
||||
cfg.Agents[agent] = Agent{
|
||||
Model: model,
|
||||
MaxTokens: maxTokens,
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -704,6 +749,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 +825,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 +842,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
internal/fileutil/fileutil.go
Normal file
163
internal/fileutil/fileutil.go
Normal 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
|
||||
}
|
||||
@@ -21,29 +21,41 @@ func PrimaryAgentTools(
|
||||
ctx := context.Background()
|
||||
mcpTools := GetMcpTools(ctx, permissions)
|
||||
|
||||
return append(
|
||||
[]tools.BaseTool{
|
||||
tools.NewBashTool(permissions),
|
||||
tools.NewEditTool(lspClients, permissions, history),
|
||||
tools.NewFetchTool(permissions),
|
||||
tools.NewGlobTool(),
|
||||
tools.NewGrepTool(),
|
||||
tools.NewLsTool(),
|
||||
tools.NewViewTool(lspClients),
|
||||
tools.NewPatchTool(lspClients, permissions, history),
|
||||
tools.NewWriteTool(lspClients, permissions, history),
|
||||
tools.NewDiagnosticsTool(lspClients),
|
||||
tools.NewDefinitionTool(lspClients),
|
||||
tools.NewReferencesTool(lspClients),
|
||||
tools.NewDocSymbolsTool(lspClients),
|
||||
tools.NewWorkspaceSymbolsTool(lspClients),
|
||||
NewAgentTool(sessions, messages, lspClients),
|
||||
}, mcpTools...,
|
||||
)
|
||||
// Create the list of tools
|
||||
toolsList := []tools.BaseTool{
|
||||
tools.NewBashTool(permissions),
|
||||
tools.NewEditTool(lspClients, permissions, history),
|
||||
tools.NewFetchTool(permissions),
|
||||
tools.NewGlobTool(),
|
||||
tools.NewGrepTool(),
|
||||
tools.NewLsTool(),
|
||||
tools.NewViewTool(lspClients),
|
||||
tools.NewPatchTool(lspClients, permissions, history),
|
||||
tools.NewWriteTool(lspClients, permissions, history),
|
||||
tools.NewDiagnosticsTool(lspClients),
|
||||
tools.NewDefinitionTool(lspClients),
|
||||
tools.NewReferencesTool(lspClients),
|
||||
tools.NewDocSymbolsTool(lspClients),
|
||||
tools.NewWorkspaceSymbolsTool(lspClients),
|
||||
tools.NewCodeActionTool(lspClients),
|
||||
NewAgentTool(sessions, messages, lspClients),
|
||||
}
|
||||
|
||||
// Create a map of tools for the batch tool
|
||||
toolsMap := make(map[string]tools.BaseTool)
|
||||
for _, tool := range toolsList {
|
||||
toolsMap[tool.Info().Name] = tool
|
||||
}
|
||||
|
||||
// Add the batch tool with access to all other tools
|
||||
toolsList = append(toolsList, tools.NewBatchTool(toolsMap))
|
||||
|
||||
return append(toolsList, mcpTools...)
|
||||
}
|
||||
|
||||
func TaskAgentTools(lspClients map[string]*lsp.Client) []tools.BaseTool {
|
||||
return []tools.BaseTool{
|
||||
// Create the list of tools
|
||||
toolsList := []tools.BaseTool{
|
||||
tools.NewGlobTool(),
|
||||
tools.NewGrepTool(),
|
||||
tools.NewLsTool(),
|
||||
@@ -53,4 +65,15 @@ func TaskAgentTools(lspClients map[string]*lsp.Client) []tools.BaseTool {
|
||||
tools.NewDocSymbolsTool(lspClients),
|
||||
tools.NewWorkspaceSymbolsTool(lspClients),
|
||||
}
|
||||
|
||||
// Create a map of tools for the batch tool
|
||||
toolsMap := make(map[string]tools.BaseTool)
|
||||
for _, tool := range toolsList {
|
||||
toolsMap[tool.Info().Name] = tool
|
||||
}
|
||||
|
||||
// Add the batch tool with access to all other tools
|
||||
toolsList = append(toolsList, tools.NewBatchTool(toolsMap))
|
||||
|
||||
return toolsList
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ var ProviderPopularity = map[ModelProvider]int{
|
||||
ProviderOpenRouter: 5,
|
||||
ProviderBedrock: 6,
|
||||
ProviderAzure: 7,
|
||||
ProviderVertexAI: 8,
|
||||
}
|
||||
|
||||
var SupportedModels = map[ModelID]Model{
|
||||
@@ -95,4 +96,5 @@ func init() {
|
||||
maps.Copy(SupportedModels, AzureModels)
|
||||
maps.Copy(SupportedModels, OpenRouterModels)
|
||||
maps.Copy(SupportedModels, XAIModels)
|
||||
maps.Copy(SupportedModels, VertexAIGeminiModels)
|
||||
}
|
||||
|
||||
38
internal/llm/models/vertexai.go
Normal file
38
internal/llm/models/vertexai.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package models
|
||||
|
||||
const (
|
||||
ProviderVertexAI ModelProvider = "vertexai"
|
||||
|
||||
// Models
|
||||
VertexAIGemini25Flash ModelID = "vertexai.gemini-2.5-flash"
|
||||
VertexAIGemini25 ModelID = "vertexai.gemini-2.5"
|
||||
)
|
||||
|
||||
var VertexAIGeminiModels = map[ModelID]Model{
|
||||
VertexAIGemini25Flash: {
|
||||
ID: VertexAIGemini25Flash,
|
||||
Name: "VertexAI: Gemini 2.5 Flash",
|
||||
Provider: ProviderVertexAI,
|
||||
APIModel: "gemini-2.5-flash-preview-04-17",
|
||||
CostPer1MIn: GeminiModels[Gemini25Flash].CostPer1MIn,
|
||||
CostPer1MInCached: GeminiModels[Gemini25Flash].CostPer1MInCached,
|
||||
CostPer1MOut: GeminiModels[Gemini25Flash].CostPer1MOut,
|
||||
CostPer1MOutCached: GeminiModels[Gemini25Flash].CostPer1MOutCached,
|
||||
ContextWindow: GeminiModels[Gemini25Flash].ContextWindow,
|
||||
DefaultMaxTokens: GeminiModels[Gemini25Flash].DefaultMaxTokens,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
VertexAIGemini25: {
|
||||
ID: VertexAIGemini25,
|
||||
Name: "VertexAI: Gemini 2.5 Pro",
|
||||
Provider: ProviderVertexAI,
|
||||
APIModel: "gemini-2.5-pro-preview-03-25",
|
||||
CostPer1MIn: GeminiModels[Gemini25].CostPer1MIn,
|
||||
CostPer1MInCached: GeminiModels[Gemini25].CostPer1MInCached,
|
||||
CostPer1MOut: GeminiModels[Gemini25].CostPer1MOut,
|
||||
CostPer1MOutCached: GeminiModels[Gemini25].CostPer1MOutCached,
|
||||
ContextWindow: GeminiModels[Gemini25].ContextWindow,
|
||||
DefaultMaxTokens: GeminiModels[Gemini25].DefaultMaxTokens,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
}
|
||||
@@ -224,15 +224,16 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message,
|
||||
if err != nil {
|
||||
slog.Error("Error in Anthropic API call", "error", err)
|
||||
retry, after, retryErr := a.shouldRetry(attempts, err)
|
||||
duration := time.Duration(after) * time.Millisecond
|
||||
if retryErr != nil {
|
||||
return nil, retryErr
|
||||
}
|
||||
if retry {
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), status.WithDuration(duration))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(time.Duration(after) * time.Millisecond):
|
||||
case <-time.After(duration):
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -360,13 +361,14 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
|
||||
}
|
||||
// If there is an error we are going to see if we can retry the call
|
||||
retry, after, retryErr := a.shouldRetry(attempts, err)
|
||||
duration := time.Duration(after) * time.Millisecond
|
||||
if retryErr != nil {
|
||||
eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
|
||||
close(eventChan)
|
||||
return
|
||||
}
|
||||
if retry {
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), status.WithDuration(duration))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// context cancelled
|
||||
@@ -375,7 +377,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
|
||||
}
|
||||
close(eventChan)
|
||||
return
|
||||
case <-time.After(time.Duration(after) * time.Millisecond):
|
||||
case <-time.After(duration):
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,13 +176,16 @@ func (g *geminiClient) send(ctx context.Context, messages []message.Message, too
|
||||
|
||||
history := geminiMessages[:len(geminiMessages)-1] // All but last message
|
||||
lastMsg := geminiMessages[len(geminiMessages)-1]
|
||||
chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, &genai.GenerateContentConfig{
|
||||
config := &genai.GenerateContentConfig{
|
||||
MaxOutputTokens: int32(g.providerOptions.maxTokens),
|
||||
SystemInstruction: &genai.Content{
|
||||
Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}},
|
||||
},
|
||||
Tools: g.convertTools(tools),
|
||||
}, history)
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
config.Tools = g.convertTools(tools)
|
||||
}
|
||||
chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, config, history)
|
||||
|
||||
attempts := 0
|
||||
for {
|
||||
@@ -197,15 +200,16 @@ func (g *geminiClient) send(ctx context.Context, messages []message.Message, too
|
||||
// If there is an error we are going to see if we can retry the call
|
||||
if err != nil {
|
||||
retry, after, retryErr := g.shouldRetry(attempts, err)
|
||||
duration := time.Duration(after) * time.Millisecond
|
||||
if retryErr != nil {
|
||||
return nil, retryErr
|
||||
}
|
||||
if retry {
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), status.WithDuration(duration))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(time.Duration(after) * time.Millisecond):
|
||||
case <-time.After(duration):
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -261,13 +265,16 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
|
||||
|
||||
history := geminiMessages[:len(geminiMessages)-1] // All but last message
|
||||
lastMsg := geminiMessages[len(geminiMessages)-1]
|
||||
chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, &genai.GenerateContentConfig{
|
||||
config := &genai.GenerateContentConfig{
|
||||
MaxOutputTokens: int32(g.providerOptions.maxTokens),
|
||||
SystemInstruction: &genai.Content{
|
||||
Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}},
|
||||
},
|
||||
Tools: g.convertTools(tools),
|
||||
}, history)
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
config.Tools = g.convertTools(tools)
|
||||
}
|
||||
chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, config, history)
|
||||
|
||||
attempts := 0
|
||||
eventChan := make(chan ProviderEvent)
|
||||
@@ -292,12 +299,13 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
|
||||
for resp, err := range chat.SendMessageStream(ctx, lastMsgParts...) {
|
||||
if err != nil {
|
||||
retry, after, retryErr := g.shouldRetry(attempts, err)
|
||||
duration := time.Duration(after) * time.Millisecond
|
||||
if retryErr != nil {
|
||||
eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
|
||||
return
|
||||
}
|
||||
if retry {
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), status.WithDuration(duration))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if ctx.Err() != nil {
|
||||
@@ -305,7 +313,7 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
|
||||
}
|
||||
|
||||
return
|
||||
case <-time.After(time.Duration(after) * time.Millisecond):
|
||||
case <-time.After(duration):
|
||||
break
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -211,15 +211,16 @@ func (o *openaiClient) send(ctx context.Context, messages []message.Message, too
|
||||
// If there is an error we are going to see if we can retry the call
|
||||
if err != nil {
|
||||
retry, after, retryErr := o.shouldRetry(attempts, err)
|
||||
duration := time.Duration(after) * time.Millisecond
|
||||
if retryErr != nil {
|
||||
return nil, retryErr
|
||||
}
|
||||
if retry {
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), status.WithDuration(duration))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(time.Duration(after) * time.Millisecond):
|
||||
case <-time.After(duration):
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -315,13 +316,14 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
|
||||
|
||||
// If there is an error we are going to see if we can retry the call
|
||||
retry, after, retryErr := o.shouldRetry(attempts, err)
|
||||
duration := time.Duration(after) * time.Millisecond
|
||||
if retryErr != nil {
|
||||
eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
|
||||
close(eventChan)
|
||||
return
|
||||
}
|
||||
if retry {
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), status.WithDuration(duration))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// context cancelled
|
||||
@@ -330,7 +332,7 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
|
||||
}
|
||||
close(eventChan)
|
||||
return
|
||||
case <-time.After(time.Duration(after) * time.Millisecond):
|
||||
case <-time.After(duration):
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,11 @@ func NewProvider(providerName models.ModelProvider, opts ...ProviderClientOption
|
||||
options: clientOptions,
|
||||
client: newAzureClient(clientOptions),
|
||||
}, nil
|
||||
case models.ProviderVertexAI:
|
||||
return &baseProvider[VertexAIClient]{
|
||||
options: clientOptions,
|
||||
client: newVertexAIClient(clientOptions),
|
||||
}, nil
|
||||
case models.ProviderOpenRouter:
|
||||
clientOptions.openaiOptions = append(clientOptions.openaiOptions,
|
||||
WithOpenAIBaseURL("https://openrouter.ai/api/v1"),
|
||||
|
||||
34
internal/llm/provider/vertexai.go
Normal file
34
internal/llm/provider/vertexai.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"google.golang.org/genai"
|
||||
)
|
||||
|
||||
type VertexAIClient ProviderClient
|
||||
|
||||
func newVertexAIClient(opts providerClientOptions) VertexAIClient {
|
||||
geminiOpts := geminiOptions{}
|
||||
for _, o := range opts.geminiOptions {
|
||||
o(&geminiOpts)
|
||||
}
|
||||
|
||||
client, err := genai.NewClient(context.Background(), &genai.ClientConfig{
|
||||
Project: os.Getenv("VERTEXAI_PROJECT"),
|
||||
Location: os.Getenv("VERTEXAI_LOCATION"),
|
||||
Backend: genai.BackendVertexAI,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("Failed to create VertexAI client", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return &geminiClient{
|
||||
providerOptions: opts,
|
||||
options: geminiOpts,
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
191
internal/llm/tools/batch.go
Normal file
191
internal/llm/tools/batch.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type BatchToolCall struct {
|
||||
Name string `json:"name"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
}
|
||||
|
||||
type BatchParams struct {
|
||||
Calls []BatchToolCall `json:"calls"`
|
||||
}
|
||||
|
||||
type BatchToolResult struct {
|
||||
ToolName string `json:"tool_name"`
|
||||
ToolInput json.RawMessage `json:"tool_input"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// Added for better formatting and separation between results
|
||||
Separator string `json:"separator,omitempty"`
|
||||
}
|
||||
|
||||
type BatchResult struct {
|
||||
Results []BatchToolResult `json:"results"`
|
||||
}
|
||||
|
||||
type batchTool struct {
|
||||
tools map[string]BaseTool
|
||||
}
|
||||
|
||||
const (
|
||||
BatchToolName = "batch"
|
||||
BatchToolDescription = `Executes multiple tool calls in parallel and returns their results.
|
||||
|
||||
WHEN TO USE THIS TOOL:
|
||||
- Use when you need to run multiple independent tool calls at once
|
||||
- Helpful for improving performance by parallelizing operations
|
||||
- Great for gathering information from multiple sources simultaneously
|
||||
|
||||
HOW TO USE:
|
||||
- Provide an array of tool calls, each with a name and input
|
||||
- Each tool call will be executed in parallel
|
||||
- Results are returned in the same order as the input calls
|
||||
|
||||
FEATURES:
|
||||
- Runs tool calls concurrently for better performance
|
||||
- Returns both results and errors for each call
|
||||
- Maintains the order of results to match input calls
|
||||
|
||||
LIMITATIONS:
|
||||
- All tools must be available in the current context
|
||||
- Complex error handling may be required for some use cases
|
||||
- Not suitable for tool calls that depend on each other's results
|
||||
|
||||
TIPS:
|
||||
- Use for independent operations like multiple file reads or searches
|
||||
- Great for batch operations like searching multiple directories
|
||||
- Combine with other tools for more complex workflows`
|
||||
)
|
||||
|
||||
func NewBatchTool(tools map[string]BaseTool) BaseTool {
|
||||
return &batchTool{
|
||||
tools: tools,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *batchTool) Info() ToolInfo {
|
||||
return ToolInfo{
|
||||
Name: BatchToolName,
|
||||
Description: BatchToolDescription,
|
||||
Parameters: map[string]any{
|
||||
"calls": map[string]any{
|
||||
"type": "array",
|
||||
"description": "Array of tool calls to execute in parallel",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"name": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Name of the tool to call",
|
||||
},
|
||||
"input": map[string]any{
|
||||
"type": "object",
|
||||
"description": "Input parameters for the tool",
|
||||
},
|
||||
},
|
||||
"required": []string{"name", "input"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"calls"},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *batchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
|
||||
var params BatchParams
|
||||
if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
|
||||
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
|
||||
}
|
||||
|
||||
if len(params.Calls) == 0 {
|
||||
return NewTextErrorResponse("no tool calls provided"), nil
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([]BatchToolResult, len(params.Calls))
|
||||
|
||||
for i, toolCall := range params.Calls {
|
||||
wg.Add(1)
|
||||
go func(index int, tc BatchToolCall) {
|
||||
defer wg.Done()
|
||||
|
||||
// Create separator for better visual distinction between results
|
||||
separator := ""
|
||||
if index > 0 {
|
||||
separator = fmt.Sprintf("\n%s\n", strings.Repeat("=", 80))
|
||||
}
|
||||
|
||||
result := BatchToolResult{
|
||||
ToolName: tc.Name,
|
||||
ToolInput: tc.Input,
|
||||
Separator: separator,
|
||||
}
|
||||
|
||||
tool, ok := b.tools[tc.Name]
|
||||
if !ok {
|
||||
result.Error = fmt.Sprintf("tool not found: %s", tc.Name)
|
||||
results[index] = result
|
||||
return
|
||||
}
|
||||
|
||||
// Create a proper ToolCall object
|
||||
callObj := ToolCall{
|
||||
ID: fmt.Sprintf("batch-%d", index),
|
||||
Name: tc.Name,
|
||||
Input: string(tc.Input),
|
||||
}
|
||||
|
||||
response, err := tool.Run(ctx, callObj)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("error executing tool %s: %s", tc.Name, err)
|
||||
results[index] = result
|
||||
return
|
||||
}
|
||||
|
||||
// Standardize metadata format if present
|
||||
if response.Metadata != "" {
|
||||
var metadata map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(response.Metadata), &metadata); err == nil {
|
||||
// Add tool name to metadata for better context
|
||||
metadata["tool"] = tc.Name
|
||||
|
||||
// Re-marshal with consistent formatting
|
||||
if metadataBytes, err := json.MarshalIndent(metadata, "", " "); err == nil {
|
||||
response.Metadata = string(metadataBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the response to JSON
|
||||
responseJSON, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("error marshaling response: %s", err)
|
||||
results[index] = result
|
||||
return
|
||||
}
|
||||
|
||||
result.Result = responseJSON
|
||||
results[index] = result
|
||||
}(i, toolCall)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
batchResult := BatchResult{
|
||||
Results: results,
|
||||
}
|
||||
|
||||
resultJSON, err := json.Marshal(batchResult)
|
||||
if err != nil {
|
||||
return NewTextErrorResponse(fmt.Sprintf("error marshaling batch result: %s", err)), nil
|
||||
}
|
||||
|
||||
return NewTextResponse(string(resultJSON)), nil
|
||||
}
|
||||
224
internal/llm/tools/batch_test.go
Normal file
224
internal/llm/tools/batch_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// MockTool is a simple tool implementation for testing
|
||||
type MockTool struct {
|
||||
name string
|
||||
description string
|
||||
response ToolResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *MockTool) Info() ToolInfo {
|
||||
return ToolInfo{
|
||||
Name: m.name,
|
||||
Description: m.description,
|
||||
Parameters: map[string]any{},
|
||||
Required: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
|
||||
return m.response, m.err
|
||||
}
|
||||
|
||||
func TestBatchTool(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("successful batch execution", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create mock tools
|
||||
mockTools := map[string]BaseTool{
|
||||
"tool1": &MockTool{
|
||||
name: "tool1",
|
||||
description: "Mock Tool 1",
|
||||
response: NewTextResponse("Tool 1 Response"),
|
||||
err: nil,
|
||||
},
|
||||
"tool2": &MockTool{
|
||||
name: "tool2",
|
||||
description: "Mock Tool 2",
|
||||
response: NewTextResponse("Tool 2 Response"),
|
||||
err: nil,
|
||||
},
|
||||
}
|
||||
|
||||
// Create batch tool
|
||||
batchTool := NewBatchTool(mockTools)
|
||||
|
||||
// Create batch call
|
||||
input := `{
|
||||
"calls": [
|
||||
{
|
||||
"name": "tool1",
|
||||
"input": {}
|
||||
},
|
||||
{
|
||||
"name": "tool2",
|
||||
"input": {}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
call := ToolCall{
|
||||
ID: "test-batch",
|
||||
Name: "batch",
|
||||
Input: input,
|
||||
}
|
||||
|
||||
// Execute batch
|
||||
response, err := batchTool.Run(context.Background(), call)
|
||||
|
||||
// Verify results
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, ToolResponseTypeText, response.Type)
|
||||
assert.False(t, response.IsError)
|
||||
|
||||
// Parse the response
|
||||
var batchResult BatchResult
|
||||
err = json.Unmarshal([]byte(response.Content), &batchResult)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify batch results
|
||||
assert.Len(t, batchResult.Results, 2)
|
||||
assert.Empty(t, batchResult.Results[0].Error)
|
||||
assert.Empty(t, batchResult.Results[1].Error)
|
||||
assert.Empty(t, batchResult.Results[0].Separator)
|
||||
assert.NotEmpty(t, batchResult.Results[1].Separator)
|
||||
|
||||
// Verify individual results
|
||||
var result1 ToolResponse
|
||||
err = json.Unmarshal(batchResult.Results[0].Result, &result1)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Tool 1 Response", result1.Content)
|
||||
|
||||
var result2 ToolResponse
|
||||
err = json.Unmarshal(batchResult.Results[1].Result, &result2)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Tool 2 Response", result2.Content)
|
||||
})
|
||||
|
||||
t.Run("tool not found", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create mock tools
|
||||
mockTools := map[string]BaseTool{
|
||||
"tool1": &MockTool{
|
||||
name: "tool1",
|
||||
description: "Mock Tool 1",
|
||||
response: NewTextResponse("Tool 1 Response"),
|
||||
err: nil,
|
||||
},
|
||||
}
|
||||
|
||||
// Create batch tool
|
||||
batchTool := NewBatchTool(mockTools)
|
||||
|
||||
// Create batch call with non-existent tool
|
||||
input := `{
|
||||
"calls": [
|
||||
{
|
||||
"name": "tool1",
|
||||
"input": {}
|
||||
},
|
||||
{
|
||||
"name": "nonexistent",
|
||||
"input": {}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
call := ToolCall{
|
||||
ID: "test-batch",
|
||||
Name: "batch",
|
||||
Input: input,
|
||||
}
|
||||
|
||||
// Execute batch
|
||||
response, err := batchTool.Run(context.Background(), call)
|
||||
|
||||
// Verify results
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, ToolResponseTypeText, response.Type)
|
||||
assert.False(t, response.IsError)
|
||||
|
||||
// Parse the response
|
||||
var batchResult BatchResult
|
||||
err = json.Unmarshal([]byte(response.Content), &batchResult)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify batch results
|
||||
assert.Len(t, batchResult.Results, 2)
|
||||
assert.Empty(t, batchResult.Results[0].Error)
|
||||
assert.Contains(t, batchResult.Results[1].Error, "tool not found: nonexistent")
|
||||
})
|
||||
|
||||
t.Run("empty calls", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create batch tool with empty tools map
|
||||
batchTool := NewBatchTool(map[string]BaseTool{})
|
||||
|
||||
// Create batch call with empty calls
|
||||
input := `{
|
||||
"calls": []
|
||||
}`
|
||||
|
||||
call := ToolCall{
|
||||
ID: "test-batch",
|
||||
Name: "batch",
|
||||
Input: input,
|
||||
}
|
||||
|
||||
// Execute batch
|
||||
response, err := batchTool.Run(context.Background(), call)
|
||||
|
||||
// Verify results
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, ToolResponseTypeText, response.Type)
|
||||
assert.True(t, response.IsError)
|
||||
assert.Contains(t, response.Content, "no tool calls provided")
|
||||
})
|
||||
|
||||
t.Run("invalid input", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create batch tool with empty tools map
|
||||
batchTool := NewBatchTool(map[string]BaseTool{})
|
||||
|
||||
// Create batch call with invalid JSON
|
||||
input := `{
|
||||
"calls": [
|
||||
{
|
||||
"name": "tool1",
|
||||
"input": {
|
||||
"invalid": json
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
call := ToolCall{
|
||||
ID: "test-batch",
|
||||
Name: "batch",
|
||||
Input: input,
|
||||
}
|
||||
|
||||
// Execute batch
|
||||
response, err := batchTool.Run(context.Background(), call)
|
||||
|
||||
// Verify results
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, ToolResponseTypeText, response.Type)
|
||||
assert.True(t, response.IsError)
|
||||
assert.Contains(t, response.Content, "error parsing parameters")
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
internal/llm/tools/lsp_code_action.go
Normal file
350
internal/llm/tools/lsp_code_action.go
Normal 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), ¶ms); 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
|
||||
}
|
||||
@@ -20,9 +20,28 @@ const (
|
||||
)
|
||||
|
||||
type StatusMessage struct {
|
||||
Level Level `json:"level"`
|
||||
Message string `json:"message"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Level Level `json:"level"`
|
||||
Message string `json:"message"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Critical bool `json:"critical"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
}
|
||||
|
||||
// StatusOption is a function that configures a status message
|
||||
type StatusOption func(*StatusMessage)
|
||||
|
||||
// WithCritical marks a status message as critical, causing it to be displayed immediately
|
||||
func WithCritical(critical bool) StatusOption {
|
||||
return func(msg *StatusMessage) {
|
||||
msg.Critical = critical
|
||||
}
|
||||
}
|
||||
|
||||
// WithDuration sets a custom display duration for a status message
|
||||
func WithDuration(duration time.Duration) StatusOption {
|
||||
return func(msg *StatusMessage) {
|
||||
msg.Duration = duration
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -32,10 +51,10 @@ const (
|
||||
type Service interface {
|
||||
pubsub.Subscriber[StatusMessage]
|
||||
|
||||
Info(message string)
|
||||
Warn(message string)
|
||||
Error(message string)
|
||||
Debug(message string)
|
||||
Info(message string, opts ...StatusOption)
|
||||
Warn(message string, opts ...StatusOption)
|
||||
Error(message string, opts ...StatusOption)
|
||||
Debug(message string, opts ...StatusOption)
|
||||
}
|
||||
|
||||
type service struct {
|
||||
@@ -63,32 +82,38 @@ func GetService() Service {
|
||||
return globalStatusService
|
||||
}
|
||||
|
||||
func (s *service) Info(message string) {
|
||||
s.publish(LevelInfo, message)
|
||||
func (s *service) Info(message string, opts ...StatusOption) {
|
||||
s.publish(LevelInfo, message, opts...)
|
||||
slog.Info(message)
|
||||
}
|
||||
|
||||
func (s *service) Warn(message string) {
|
||||
s.publish(LevelWarn, message)
|
||||
func (s *service) Warn(message string, opts ...StatusOption) {
|
||||
s.publish(LevelWarn, message, opts...)
|
||||
slog.Warn(message)
|
||||
}
|
||||
|
||||
func (s *service) Error(message string) {
|
||||
s.publish(LevelError, message)
|
||||
func (s *service) Error(message string, opts ...StatusOption) {
|
||||
s.publish(LevelError, message, opts...)
|
||||
slog.Error(message)
|
||||
}
|
||||
|
||||
func (s *service) Debug(message string) {
|
||||
s.publish(LevelDebug, message)
|
||||
func (s *service) Debug(message string, opts ...StatusOption) {
|
||||
s.publish(LevelDebug, message, opts...)
|
||||
slog.Debug(message)
|
||||
}
|
||||
|
||||
func (s *service) publish(level Level, messageText string) {
|
||||
func (s *service) publish(level Level, messageText string, opts ...StatusOption) {
|
||||
statusMsg := StatusMessage{
|
||||
Level: level,
|
||||
Message: messageText,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
// Apply all options
|
||||
for _, opt := range opts {
|
||||
opt(&statusMsg)
|
||||
}
|
||||
|
||||
s.broker.Publish(EventStatusPublished, statusMsg)
|
||||
}
|
||||
|
||||
@@ -96,20 +121,20 @@ func (s *service) Subscribe(ctx context.Context) <-chan pubsub.Event[StatusMessa
|
||||
return s.broker.Subscribe(ctx)
|
||||
}
|
||||
|
||||
func Info(message string) {
|
||||
GetService().Info(message)
|
||||
func Info(message string, opts ...StatusOption) {
|
||||
GetService().Info(message, opts...)
|
||||
}
|
||||
|
||||
func Warn(message string) {
|
||||
GetService().Warn(message)
|
||||
func Warn(message string, opts ...StatusOption) {
|
||||
GetService().Warn(message, opts...)
|
||||
}
|
||||
|
||||
func Error(message string) {
|
||||
GetService().Error(message)
|
||||
func Error(message string, opts ...StatusOption) {
|
||||
GetService().Error(message, opts...)
|
||||
}
|
||||
|
||||
func Debug(message string) {
|
||||
GetService().Debug(message)
|
||||
func Debug(message string, opts ...StatusOption) {
|
||||
GetService().Debug(message, opts...)
|
||||
}
|
||||
|
||||
func Subscribe(ctx context.Context) <-chan pubsub.Event[StatusMessage] {
|
||||
|
||||
@@ -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 {
|
||||
@@ -237,7 +243,6 @@ func (m *editorCmp) SetSize(width, height int) tea.Cmd {
|
||||
m.height = height
|
||||
m.textarea.SetWidth(width - 3) // account for the prompt and padding right
|
||||
m.textarea.SetHeight(height)
|
||||
m.textarea.SetWidth(width)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -266,6 +266,8 @@ func toolName(name string) string {
|
||||
return "Write"
|
||||
case tools.PatchToolName:
|
||||
return "Patch"
|
||||
case tools.BatchToolName:
|
||||
return "Batch"
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -292,6 +294,8 @@ func getToolAction(name string) string {
|
||||
return "Preparing write..."
|
||||
case tools.PatchToolName:
|
||||
return "Preparing patch..."
|
||||
case tools.BatchToolName:
|
||||
return "Running batch operations..."
|
||||
}
|
||||
return "Working..."
|
||||
}
|
||||
@@ -443,6 +447,10 @@ func renderToolParams(paramWidth int, toolCall message.ToolCall) string {
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
filePath := removeWorkingDirPrefix(params.FilePath)
|
||||
return renderParams(paramWidth, filePath)
|
||||
case tools.BatchToolName:
|
||||
var params tools.BatchParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
return renderParams(paramWidth, fmt.Sprintf("%d parallel calls", len(params.Calls)))
|
||||
default:
|
||||
input := strings.ReplaceAll(toolCall.Input, "\n", " ")
|
||||
params = renderParams(paramWidth, input)
|
||||
@@ -540,6 +548,38 @@ func renderToolResponse(toolCall message.ToolCall, response message.ToolResult,
|
||||
toMarkdown(resultContent, true, width),
|
||||
t.Background(),
|
||||
)
|
||||
case tools.BatchToolName:
|
||||
var batchResult tools.BatchResult
|
||||
if err := json.Unmarshal([]byte(resultContent), &batchResult); err != nil {
|
||||
return baseStyle.Width(width).Foreground(t.Error()).Render(fmt.Sprintf("Error parsing batch result: %s", err))
|
||||
}
|
||||
|
||||
var toolCalls []string
|
||||
for i, result := range batchResult.Results {
|
||||
toolName := toolName(result.ToolName)
|
||||
|
||||
// Format the tool input as a string
|
||||
inputStr := string(result.ToolInput)
|
||||
|
||||
// Format the result
|
||||
var resultStr string
|
||||
if result.Error != "" {
|
||||
resultStr = fmt.Sprintf("Error: %s", result.Error)
|
||||
} else {
|
||||
var toolResponse tools.ToolResponse
|
||||
if err := json.Unmarshal(result.Result, &toolResponse); err != nil {
|
||||
resultStr = "Error parsing tool response"
|
||||
} else {
|
||||
resultStr = truncateHeight(toolResponse.Content, 3)
|
||||
}
|
||||
}
|
||||
|
||||
// Format the tool call
|
||||
toolCall := fmt.Sprintf("%d. %s: %s\n %s", i+1, toolName, inputStr, resultStr)
|
||||
toolCalls = append(toolCalls, toolCall)
|
||||
}
|
||||
|
||||
return baseStyle.Width(width).Foreground(t.TextMuted()).Render(strings.Join(toolCalls, "\n\n"))
|
||||
default:
|
||||
resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
|
||||
return styles.ForceReplaceBackgroundWithLipgloss(
|
||||
|
||||
@@ -71,8 +71,7 @@ func (m *sidebarCmp) View() string {
|
||||
return baseStyle.
|
||||
Width(m.width).
|
||||
PaddingLeft(4).
|
||||
PaddingRight(2).
|
||||
Height(m.height - 1).
|
||||
PaddingRight(1).
|
||||
Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Top,
|
||||
@@ -98,14 +97,9 @@ func (m *sidebarCmp) sessionSection() string {
|
||||
|
||||
sessionValue := baseStyle.
|
||||
Foreground(t.Text()).
|
||||
Width(m.width - lipgloss.Width(sessionKey)).
|
||||
Render(fmt.Sprintf(": %s", m.app.CurrentSession.Title))
|
||||
|
||||
return lipgloss.JoinHorizontal(
|
||||
lipgloss.Left,
|
||||
sessionKey,
|
||||
sessionValue,
|
||||
)
|
||||
return sessionKey + sessionValue
|
||||
}
|
||||
|
||||
func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
|
||||
|
||||
@@ -24,17 +24,11 @@ type StatusCmp interface {
|
||||
}
|
||||
|
||||
type statusCmp struct {
|
||||
app *app.App
|
||||
statusMessages []statusMessage
|
||||
width int
|
||||
messageTTL time.Duration
|
||||
}
|
||||
|
||||
type statusMessage struct {
|
||||
Level status.Level
|
||||
Message string
|
||||
Timestamp time.Time
|
||||
ExpiresAt time.Time
|
||||
app *app.App
|
||||
queue []status.StatusMessage
|
||||
width int
|
||||
messageTTL time.Duration
|
||||
activeUntil time.Time
|
||||
}
|
||||
|
||||
// clearMessageCmd is a command that clears status messages after a timeout
|
||||
@@ -60,23 +54,50 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
case pubsub.Event[status.StatusMessage]:
|
||||
if msg.Type == status.EventStatusPublished {
|
||||
statusMsg := statusMessage{
|
||||
Level: msg.Payload.Level,
|
||||
Message: msg.Payload.Message,
|
||||
Timestamp: msg.Payload.Timestamp,
|
||||
ExpiresAt: msg.Payload.Timestamp.Add(m.messageTTL),
|
||||
// If this is a critical message, move it to the front of the queue
|
||||
if msg.Payload.Critical {
|
||||
// Insert at the front of the queue
|
||||
m.queue = append([]status.StatusMessage{msg.Payload}, m.queue...)
|
||||
|
||||
// Reset active time to show critical message immediately
|
||||
m.activeUntil = time.Time{}
|
||||
} else {
|
||||
// Otherwise, just add it to the queue
|
||||
m.queue = append(m.queue, msg.Payload)
|
||||
|
||||
// If this is the first message and nothing is active, activate it immediately
|
||||
if len(m.queue) == 1 && m.activeUntil.IsZero() {
|
||||
now := time.Now()
|
||||
duration := m.messageTTL
|
||||
if msg.Payload.Duration > 0 {
|
||||
duration = msg.Payload.Duration
|
||||
}
|
||||
m.activeUntil = now.Add(duration)
|
||||
}
|
||||
}
|
||||
m.statusMessages = append(m.statusMessages, statusMsg)
|
||||
}
|
||||
case statusCleanupMsg:
|
||||
// Remove expired messages
|
||||
var activeMessages []statusMessage
|
||||
for _, sm := range m.statusMessages {
|
||||
if sm.ExpiresAt.After(msg.time) {
|
||||
activeMessages = append(activeMessages, sm)
|
||||
now := msg.time
|
||||
|
||||
// If the active message has expired, remove it and activate the next one
|
||||
if !m.activeUntil.IsZero() && m.activeUntil.Before(now) {
|
||||
// Current message expired, remove it if we have one
|
||||
if len(m.queue) > 0 {
|
||||
m.queue = m.queue[1:]
|
||||
}
|
||||
m.activeUntil = time.Time{}
|
||||
}
|
||||
m.statusMessages = activeMessages
|
||||
|
||||
// If we have messages in queue but none are active, activate the first one
|
||||
if len(m.queue) > 0 && m.activeUntil.IsZero() {
|
||||
// Use custom duration if specified, otherwise use default
|
||||
duration := m.messageTTL
|
||||
if m.queue[0].Duration > 0 {
|
||||
duration = m.queue[0].Duration
|
||||
}
|
||||
m.activeUntil = now.Add(duration)
|
||||
}
|
||||
|
||||
return m, m.clearMessageCmd()
|
||||
}
|
||||
return m, nil
|
||||
@@ -155,12 +176,14 @@ func (m statusCmp) View() string {
|
||||
lipgloss.Width(diagnostics),
|
||||
)
|
||||
|
||||
const minInlineWidth = 30
|
||||
|
||||
// Display the first status message if available
|
||||
if len(m.statusMessages) > 0 {
|
||||
sm := m.statusMessages[0]
|
||||
var statusMessage string
|
||||
if len(m.queue) > 0 {
|
||||
sm := m.queue[0]
|
||||
infoStyle := styles.Padded().
|
||||
Foreground(t.Background()).
|
||||
Width(statusWidth)
|
||||
Foreground(t.Background())
|
||||
|
||||
switch sm.Level {
|
||||
case "info":
|
||||
@@ -176,11 +199,27 @@ func (m statusCmp) View() string {
|
||||
// Truncate message if it's longer than available width
|
||||
msg := sm.Message
|
||||
availWidth := statusWidth - 10
|
||||
if len(msg) > availWidth && availWidth > 0 {
|
||||
msg = msg[:availWidth] + "..."
|
||||
}
|
||||
|
||||
status += infoStyle.Render(msg)
|
||||
// If we have enough space, show inline
|
||||
if availWidth >= minInlineWidth {
|
||||
if len(msg) > availWidth && availWidth > 0 {
|
||||
msg = msg[:availWidth] + "..."
|
||||
}
|
||||
status += infoStyle.Width(statusWidth).Render(msg)
|
||||
} else {
|
||||
// Otherwise, prepare a full-width message to show above
|
||||
if len(msg) > m.width-10 && m.width > 10 {
|
||||
msg = msg[:m.width-10] + "..."
|
||||
}
|
||||
statusMessage = infoStyle.Width(m.width).Render(msg)
|
||||
|
||||
// Add empty space in the status bar
|
||||
status += styles.Padded().
|
||||
Foreground(t.Text()).
|
||||
Background(t.BackgroundSecondary()).
|
||||
Width(statusWidth).
|
||||
Render("")
|
||||
}
|
||||
} else {
|
||||
status += styles.Padded().
|
||||
Foreground(t.Text()).
|
||||
@@ -191,7 +230,14 @@ func (m statusCmp) View() string {
|
||||
|
||||
status += diagnostics
|
||||
status += modelName
|
||||
return status
|
||||
|
||||
// If we have a separate status message, prepend it
|
||||
if statusMessage != "" {
|
||||
return statusMessage + "\n" + status
|
||||
} else {
|
||||
blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
|
||||
return blank + "\n" + status
|
||||
}
|
||||
}
|
||||
|
||||
func (m *statusCmp) projectDiagnostics() string {
|
||||
@@ -234,6 +280,16 @@ func (m *statusCmp) projectDiagnostics() string {
|
||||
}
|
||||
}
|
||||
|
||||
if len(errorDiagnostics) == 0 &&
|
||||
len(warnDiagnostics) == 0 &&
|
||||
len(infoDiagnostics) == 0 &&
|
||||
len(hintDiagnostics) == 0 {
|
||||
return styles.ForceReplaceBackgroundWithLipgloss(
|
||||
styles.Padded().Render("No diagnostics"),
|
||||
t.BackgroundDarker(),
|
||||
)
|
||||
}
|
||||
|
||||
diagnostics := []string{}
|
||||
|
||||
errStr := lipgloss.NewStyle().
|
||||
@@ -293,9 +349,10 @@ func NewStatusCmp(app *app.App) StatusCmp {
|
||||
helpWidget = getHelpWidget("")
|
||||
|
||||
statusComponent := &statusCmp{
|
||||
app: app,
|
||||
statusMessages: []statusMessage{},
|
||||
messageTTL: 4 * time.Second,
|
||||
app: app,
|
||||
queue: []status.StatusMessage{},
|
||||
messageTTL: 4 * time.Second,
|
||||
activeUntil: time.Time{},
|
||||
}
|
||||
|
||||
return statusComponent
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@@ -11,35 +12,6 @@ import (
|
||||
"github.com/sst/opencode/internal/tui/util"
|
||||
)
|
||||
|
||||
// ArgumentsDialogCmp is a component that asks the user for command arguments.
|
||||
type ArgumentsDialogCmp struct {
|
||||
width, height int
|
||||
textInput textinput.Model
|
||||
keys argumentsDialogKeyMap
|
||||
commandID string
|
||||
content string
|
||||
}
|
||||
|
||||
// NewArgumentsDialogCmp creates a new ArgumentsDialogCmp.
|
||||
func NewArgumentsDialogCmp(commandID, content string) ArgumentsDialogCmp {
|
||||
t := theme.CurrentTheme()
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "Enter arguments..."
|
||||
ti.Focus()
|
||||
ti.Width = 40
|
||||
ti.Prompt = ""
|
||||
ti.PlaceholderStyle = ti.PlaceholderStyle.Background(t.Background())
|
||||
ti.PromptStyle = ti.PromptStyle.Background(t.Background())
|
||||
ti.TextStyle = ti.TextStyle.Background(t.Background())
|
||||
|
||||
return ArgumentsDialogCmp{
|
||||
textInput: ti,
|
||||
keys: argumentsDialogKeyMap{},
|
||||
commandID: commandID,
|
||||
content: content,
|
||||
}
|
||||
}
|
||||
|
||||
type argumentsDialogKeyMap struct {
|
||||
Enter key.Binding
|
||||
Escape key.Binding
|
||||
@@ -64,77 +36,204 @@ func (k argumentsDialogKeyMap) FullHelp() [][]key.Binding {
|
||||
return [][]key.Binding{k.ShortHelp()}
|
||||
}
|
||||
|
||||
// ShowMultiArgumentsDialogMsg is a message that is sent to show the multi-arguments dialog.
|
||||
type ShowMultiArgumentsDialogMsg struct {
|
||||
CommandID string
|
||||
Content string
|
||||
ArgNames []string
|
||||
}
|
||||
|
||||
// CloseMultiArgumentsDialogMsg is a message that is sent when the multi-arguments dialog is closed.
|
||||
type CloseMultiArgumentsDialogMsg struct {
|
||||
Submit bool
|
||||
CommandID string
|
||||
Content string
|
||||
Args map[string]string
|
||||
}
|
||||
|
||||
// MultiArgumentsDialogCmp is a component that asks the user for multiple command arguments.
|
||||
type MultiArgumentsDialogCmp struct {
|
||||
width, height int
|
||||
inputs []textinput.Model
|
||||
focusIndex int
|
||||
keys argumentsDialogKeyMap
|
||||
commandID string
|
||||
content string
|
||||
argNames []string
|
||||
}
|
||||
|
||||
// NewMultiArgumentsDialogCmp creates a new MultiArgumentsDialogCmp.
|
||||
func NewMultiArgumentsDialogCmp(commandID, content string, argNames []string) MultiArgumentsDialogCmp {
|
||||
t := theme.CurrentTheme()
|
||||
inputs := make([]textinput.Model, len(argNames))
|
||||
|
||||
for i, name := range argNames {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = fmt.Sprintf("Enter value for %s...", name)
|
||||
ti.Width = 40
|
||||
ti.Prompt = ""
|
||||
ti.PlaceholderStyle = ti.PlaceholderStyle.Background(t.Background())
|
||||
ti.PromptStyle = ti.PromptStyle.Background(t.Background())
|
||||
ti.TextStyle = ti.TextStyle.Background(t.Background())
|
||||
|
||||
// Only focus the first input initially
|
||||
if i == 0 {
|
||||
ti.Focus()
|
||||
ti.PromptStyle = ti.PromptStyle.Foreground(t.Primary())
|
||||
ti.TextStyle = ti.TextStyle.Foreground(t.Primary())
|
||||
} else {
|
||||
ti.Blur()
|
||||
}
|
||||
|
||||
inputs[i] = ti
|
||||
}
|
||||
|
||||
return MultiArgumentsDialogCmp{
|
||||
inputs: inputs,
|
||||
keys: argumentsDialogKeyMap{},
|
||||
commandID: commandID,
|
||||
content: content,
|
||||
argNames: argNames,
|
||||
focusIndex: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Init implements tea.Model.
|
||||
func (m ArgumentsDialogCmp) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
textinput.Blink,
|
||||
m.textInput.Focus(),
|
||||
)
|
||||
func (m MultiArgumentsDialogCmp) Init() tea.Cmd {
|
||||
// Make sure only the first input is focused
|
||||
for i := range m.inputs {
|
||||
if i == 0 {
|
||||
m.inputs[i].Focus()
|
||||
} else {
|
||||
m.inputs[i].Blur()
|
||||
}
|
||||
}
|
||||
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
// Update implements tea.Model.
|
||||
func (m ArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
func (m MultiArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
|
||||
return m, util.CmdHandler(CloseArgumentsDialogMsg{})
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
|
||||
return m, util.CmdHandler(CloseArgumentsDialogMsg{
|
||||
Submit: true,
|
||||
return m, util.CmdHandler(CloseMultiArgumentsDialogMsg{
|
||||
Submit: false,
|
||||
CommandID: m.commandID,
|
||||
Content: m.content,
|
||||
Arguments: m.textInput.Value(),
|
||||
Args: nil,
|
||||
})
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
|
||||
// If we're on the last input, submit the form
|
||||
if m.focusIndex == len(m.inputs)-1 {
|
||||
args := make(map[string]string)
|
||||
for i, name := range m.argNames {
|
||||
args[name] = m.inputs[i].Value()
|
||||
}
|
||||
return m, util.CmdHandler(CloseMultiArgumentsDialogMsg{
|
||||
Submit: true,
|
||||
CommandID: m.commandID,
|
||||
Content: m.content,
|
||||
Args: args,
|
||||
})
|
||||
}
|
||||
// Otherwise, move to the next input
|
||||
m.inputs[m.focusIndex].Blur()
|
||||
m.focusIndex++
|
||||
m.inputs[m.focusIndex].Focus()
|
||||
m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
|
||||
m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
|
||||
// Move to the next input
|
||||
m.inputs[m.focusIndex].Blur()
|
||||
m.focusIndex = (m.focusIndex + 1) % len(m.inputs)
|
||||
m.inputs[m.focusIndex].Focus()
|
||||
m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
|
||||
m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("shift+tab"))):
|
||||
// Move to the previous input
|
||||
m.inputs[m.focusIndex].Blur()
|
||||
m.focusIndex = (m.focusIndex - 1 + len(m.inputs)) % len(m.inputs)
|
||||
m.inputs[m.focusIndex].Focus()
|
||||
m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
|
||||
m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
}
|
||||
|
||||
m.textInput, cmd = m.textInput.Update(msg)
|
||||
// Update the focused input
|
||||
var cmd tea.Cmd
|
||||
m.inputs[m.focusIndex], cmd = m.inputs[m.focusIndex].Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// View implements tea.Model.
|
||||
func (m ArgumentsDialogCmp) View() string {
|
||||
func (m MultiArgumentsDialogCmp) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.BaseStyle()
|
||||
|
||||
// Calculate width needed for content
|
||||
maxWidth := 60 // Width for explanation text
|
||||
|
||||
title := baseStyle.
|
||||
title := lipgloss.NewStyle().
|
||||
Foreground(t.Primary()).
|
||||
Bold(true).
|
||||
Width(maxWidth).
|
||||
Padding(0, 1).
|
||||
Background(t.Background()).
|
||||
Render("Command Arguments")
|
||||
|
||||
explanation := baseStyle.
|
||||
explanation := lipgloss.NewStyle().
|
||||
Foreground(t.Text()).
|
||||
Width(maxWidth).
|
||||
Padding(0, 1).
|
||||
Render("This command requires arguments. Please enter the text to replace $ARGUMENTS with:")
|
||||
Background(t.Background()).
|
||||
Render("This command requires multiple arguments. Please enter values for each:")
|
||||
|
||||
inputField := baseStyle.
|
||||
Foreground(t.Text()).
|
||||
Width(maxWidth).
|
||||
Padding(1, 1).
|
||||
Render(m.textInput.View())
|
||||
// Create input fields for each argument
|
||||
inputFields := make([]string, len(m.inputs))
|
||||
for i, input := range m.inputs {
|
||||
// Highlight the label of the focused input
|
||||
labelStyle := lipgloss.NewStyle().
|
||||
Width(maxWidth).
|
||||
Padding(1, 1, 0, 1).
|
||||
Background(t.Background())
|
||||
|
||||
if i == m.focusIndex {
|
||||
labelStyle = labelStyle.Foreground(t.Primary()).Bold(true)
|
||||
} else {
|
||||
labelStyle = labelStyle.Foreground(t.TextMuted())
|
||||
}
|
||||
|
||||
label := labelStyle.Render(m.argNames[i] + ":")
|
||||
|
||||
field := lipgloss.NewStyle().
|
||||
Foreground(t.Text()).
|
||||
Width(maxWidth).
|
||||
Padding(0, 1).
|
||||
Background(t.Background()).
|
||||
Render(input.View())
|
||||
|
||||
inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field)
|
||||
}
|
||||
|
||||
maxWidth = min(maxWidth, m.width-10)
|
||||
|
||||
// Join all elements vertically
|
||||
elements := []string{title, explanation}
|
||||
elements = append(elements, inputFields...)
|
||||
|
||||
content := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
title,
|
||||
explanation,
|
||||
inputField,
|
||||
elements...,
|
||||
)
|
||||
|
||||
return baseStyle.Padding(1, 2).
|
||||
@@ -147,26 +246,12 @@ func (m ArgumentsDialogCmp) View() string {
|
||||
}
|
||||
|
||||
// SetSize sets the size of the component.
|
||||
func (m *ArgumentsDialogCmp) SetSize(width, height int) {
|
||||
func (m *MultiArgumentsDialogCmp) SetSize(width, height int) {
|
||||
m.width = width
|
||||
m.height = height
|
||||
}
|
||||
|
||||
// Bindings implements layout.Bindings.
|
||||
func (m ArgumentsDialogCmp) Bindings() []key.Binding {
|
||||
func (m MultiArgumentsDialogCmp) Bindings() []key.Binding {
|
||||
return m.keys.ShortHelp()
|
||||
}
|
||||
|
||||
// CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed.
|
||||
type CloseArgumentsDialogMsg struct {
|
||||
Submit bool
|
||||
CommandID string
|
||||
Content string
|
||||
Arguments string
|
||||
}
|
||||
|
||||
// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog.
|
||||
type ShowArgumentsDialogMsg struct {
|
||||
CommandID string
|
||||
Content string
|
||||
}
|
||||
|
||||
@@ -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
internal/tui/components/dialog/complete.go
Normal file
263
internal/tui/components/dialog/complete.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@@ -17,6 +18,9 @@ const (
|
||||
ProjectCommandPrefix = "project:"
|
||||
)
|
||||
|
||||
// namedArgPattern is a regex pattern to find named arguments in the format $NAME
|
||||
var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
|
||||
|
||||
// LoadCustomCommands loads custom commands from both XDG_CONFIG_HOME and project data directory
|
||||
func LoadCustomCommands() ([]Command, error) {
|
||||
cfg := config.Get()
|
||||
@@ -133,18 +137,33 @@ func loadCommandsFromDir(commandsDir string, prefix string) ([]Command, error) {
|
||||
Handler: func(cmd Command) tea.Cmd {
|
||||
commandContent := string(content)
|
||||
|
||||
// Check if the command contains $ARGUMENTS placeholder
|
||||
if strings.Contains(commandContent, "$ARGUMENTS") {
|
||||
// Show arguments dialog
|
||||
return util.CmdHandler(ShowArgumentsDialogMsg{
|
||||
// Check for named arguments
|
||||
matches := namedArgPattern.FindAllStringSubmatch(commandContent, -1)
|
||||
if len(matches) > 0 {
|
||||
// Extract unique argument names
|
||||
argNames := make([]string, 0)
|
||||
argMap := make(map[string]bool)
|
||||
|
||||
for _, match := range matches {
|
||||
argName := match[1] // Group 1 is the name without $
|
||||
if !argMap[argName] {
|
||||
argMap[argName] = true
|
||||
argNames = append(argNames, argName)
|
||||
}
|
||||
}
|
||||
|
||||
// Show multi-arguments dialog for all named arguments
|
||||
return util.CmdHandler(ShowMultiArgumentsDialogMsg{
|
||||
CommandID: cmd.ID,
|
||||
Content: commandContent,
|
||||
ArgNames: argNames,
|
||||
})
|
||||
}
|
||||
|
||||
// No arguments needed, run command directly
|
||||
return util.CmdHandler(CommandRunCustomMsg{
|
||||
Content: commandContent,
|
||||
Args: nil, // No arguments
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -163,4 +182,5 @@ func loadCommandsFromDir(commandsDir string, prefix string) ([]Command, error) {
|
||||
// CommandRunCustomMsg is sent when a custom command is executed
|
||||
type CommandRunCustomMsg struct {
|
||||
Content string
|
||||
Args map[string]string // Map of argument names to values
|
||||
}
|
||||
|
||||
106
internal/tui/components/dialog/custom_commands_test.go
Normal file
106
internal/tui/components/dialog/custom_commands_test.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
func TestNamedArgPattern(t *testing.T) {
|
||||
testCases := []struct {
|
||||
input string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
input: "This is a test with $ARGUMENTS placeholder",
|
||||
expected: []string{"ARGUMENTS"},
|
||||
},
|
||||
{
|
||||
input: "This is a test with $FOO and $BAR placeholders",
|
||||
expected: []string{"FOO", "BAR"},
|
||||
},
|
||||
{
|
||||
input: "This is a test with $FOO_BAR and $BAZ123 placeholders",
|
||||
expected: []string{"FOO_BAR", "BAZ123"},
|
||||
},
|
||||
{
|
||||
input: "This is a test with no placeholders",
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
input: "This is a test with $FOO appearing twice: $FOO",
|
||||
expected: []string{"FOO"},
|
||||
},
|
||||
{
|
||||
input: "This is a test with $1INVALID placeholder",
|
||||
expected: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
matches := namedArgPattern.FindAllStringSubmatch(tc.input, -1)
|
||||
|
||||
// Extract unique argument names
|
||||
argNames := make([]string, 0)
|
||||
argMap := make(map[string]bool)
|
||||
|
||||
for _, match := range matches {
|
||||
argName := match[1] // Group 1 is the name without $
|
||||
if !argMap[argName] {
|
||||
argMap[argName] = true
|
||||
argNames = append(argNames, argName)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we got the expected number of arguments
|
||||
if len(argNames) != len(tc.expected) {
|
||||
t.Errorf("Expected %d arguments, got %d for input: %s", len(tc.expected), len(argNames), tc.input)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if we got the expected argument names
|
||||
for _, expectedArg := range tc.expected {
|
||||
found := false
|
||||
for _, actualArg := range argNames {
|
||||
if actualArg == expectedArg {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Expected argument %s not found in %v for input: %s", expectedArg, argNames, tc.input)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegexPattern(t *testing.T) {
|
||||
pattern := regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
|
||||
|
||||
validMatches := []string{
|
||||
"$FOO",
|
||||
"$BAR",
|
||||
"$FOO_BAR",
|
||||
"$BAZ123",
|
||||
"$ARGUMENTS",
|
||||
}
|
||||
|
||||
invalidMatches := []string{
|
||||
"$foo",
|
||||
"$1BAR",
|
||||
"$_FOO",
|
||||
"FOO",
|
||||
"$",
|
||||
}
|
||||
|
||||
for _, valid := range validMatches {
|
||||
if !pattern.MatchString(valid) {
|
||||
t.Errorf("Expected %s to match, but it didn't", valid)
|
||||
}
|
||||
}
|
||||
|
||||
for _, invalid := range invalidMatches {
|
||||
if pattern.MatchString(invalid) {
|
||||
t.Errorf("Expected %s not to match, but it did", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"log/slog"
|
||||
|
||||
"github.com/atotto/clipboard"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
@@ -22,7 +25,6 @@ import (
|
||||
"github.com/sst/opencode/internal/tui/styles"
|
||||
"github.com/sst/opencode/internal/tui/theme"
|
||||
"github.com/sst/opencode/internal/tui/util"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -40,6 +42,7 @@ type FilePrickerKeyMap struct {
|
||||
OpenFilePicker key.Binding
|
||||
Esc key.Binding
|
||||
InsertCWD key.Binding
|
||||
Paste key.Binding
|
||||
}
|
||||
|
||||
var filePickerKeyMap = FilePrickerKeyMap{
|
||||
@@ -75,6 +78,10 @@ var filePickerKeyMap = FilePrickerKeyMap{
|
||||
key.WithKeys("i"),
|
||||
key.WithHelp("i", "manual path input"),
|
||||
),
|
||||
Paste: key.NewBinding(
|
||||
key.WithKeys("ctrl+v"),
|
||||
key.WithHelp("ctrl+v", "paste file/directory path"),
|
||||
),
|
||||
}
|
||||
|
||||
type filepickerCmp struct {
|
||||
@@ -213,6 +220,15 @@ func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
f.getCurrentFileBelowCursor()
|
||||
}
|
||||
}
|
||||
case key.Matches(msg, filePickerKeyMap.Paste):
|
||||
if f.cwd.Focused() {
|
||||
val, err := clipboard.ReadAll()
|
||||
if err != nil {
|
||||
slog.Error("failed to read clipboard")
|
||||
return f, cmd
|
||||
}
|
||||
f.cwd.SetValue(f.cwd.Value() + val)
|
||||
}
|
||||
case key.Matches(msg, filePickerKeyMap.OpenFilePicker):
|
||||
f.dirs = readDir(f.cwdDetails.directory, false)
|
||||
f.cursor = 0
|
||||
@@ -303,10 +319,6 @@ func (f *filepickerCmp) View() string {
|
||||
}
|
||||
if file.IsDir() {
|
||||
filename = filename + "/"
|
||||
} else if isExtSupported(file.Name()) {
|
||||
filename = filename
|
||||
} else {
|
||||
filename = filename
|
||||
}
|
||||
|
||||
files = append(files, itemStyle.Padding(0, 1).Render(filename))
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/llm/models"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
"github.com/sst/opencode/internal/tui/layout"
|
||||
"github.com/sst/opencode/internal/tui/styles"
|
||||
"github.com/sst/opencode/internal/tui/theme"
|
||||
@@ -127,7 +126,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.switchProvider(1)
|
||||
}
|
||||
case key.Matches(msg, modelKeys.Enter):
|
||||
status.Info(fmt.Sprintf("selected model: %s", m.models[m.selectedIdx].Name))
|
||||
return m, util.CmdHandler(ModelSelectedMsg{Model: m.models[m.selectedIdx]})
|
||||
case key.Matches(msg, modelKeys.Escape):
|
||||
return m, util.CmdHandler(CloseModelDialogMsg{})
|
||||
|
||||
@@ -52,11 +52,6 @@ func (i *tableCmp) fetchLogs() tea.Cmd {
|
||||
logs, err = i.app.Logs.ListAll(ctx, logLimit)
|
||||
} else {
|
||||
logs, err = i.app.Logs.ListBySession(ctx, i.app.CurrentSession.ID)
|
||||
|
||||
// Trim logs if there are too many
|
||||
if err == nil && len(logs) > logLimit {
|
||||
logs = logs[len(logs)-logLimit:]
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
|
||||
159
internal/tui/components/util/simple-list.go
Normal file
159
internal/tui/components/util/simple-list.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/lucasb-eyer/go-colorful"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
func ValidateFileSize(filePath string, sizeLimit int64) (bool, error) {
|
||||
|
||||
@@ -11,16 +11,16 @@ type Container interface {
|
||||
tea.Model
|
||||
Sizeable
|
||||
Bindings
|
||||
Focus() // Add focus method
|
||||
Blur() // Add blur method
|
||||
Focus()
|
||||
Blur()
|
||||
}
|
||||
|
||||
type container struct {
|
||||
width int
|
||||
height int
|
||||
|
||||
content tea.Model
|
||||
|
||||
// Style options
|
||||
paddingTop int
|
||||
paddingRight int
|
||||
paddingBottom int
|
||||
@@ -32,7 +32,7 @@ type container struct {
|
||||
borderLeft bool
|
||||
borderStyle lipgloss.Border
|
||||
|
||||
focused bool // Track focus state
|
||||
focused bool
|
||||
}
|
||||
|
||||
func (c *container) Init() tea.Cmd {
|
||||
@@ -152,16 +152,13 @@ func (c *container) Blur() {
|
||||
type ContainerOption func(*container)
|
||||
|
||||
func NewContainer(content tea.Model, options ...ContainerOption) Container {
|
||||
|
||||
c := &container{
|
||||
content: content,
|
||||
borderStyle: lipgloss.NormalBorder(),
|
||||
}
|
||||
|
||||
for _, option := range options {
|
||||
option(c)
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,13 @@ package page
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"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 +23,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 +51,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...)
|
||||
}
|
||||
|
||||
@@ -71,8 +82,19 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
status.Warn("Agent is busy, please wait before executing a command...")
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Process the command content with arguments if any
|
||||
content := msg.Content
|
||||
if msg.Args != nil {
|
||||
// Replace all named arguments with their values
|
||||
for name, value := range msg.Args {
|
||||
placeholder := "$" + name
|
||||
content = strings.ReplaceAll(content, placeholder, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle custom command execution
|
||||
cmd := p.sendMessage(msg.Content, nil)
|
||||
cmd := p.sendMessage(content, nil)
|
||||
if cmd != nil {
|
||||
return p, cmd
|
||||
}
|
||||
@@ -99,8 +121,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 +145,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 +211,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 +240,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 +251,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),
|
||||
|
||||
@@ -10,16 +10,3 @@ const (
|
||||
SpinnerIcon string = "⟳"
|
||||
DocumentIcon string = "🖼"
|
||||
)
|
||||
|
||||
// CircledDigit returns the Unicode circled digit/number for 0‑20.
|
||||
// out‑of‑range → "".
|
||||
func CircledDigit(n int) string {
|
||||
switch {
|
||||
case n == 0:
|
||||
return "\u24EA" // ⓪
|
||||
case 1 <= n && n <= 20:
|
||||
return string(rune(0x2460 + n - 1)) // ①–⑳
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,8 +135,8 @@ type appModel struct {
|
||||
showThemeDialog bool
|
||||
themeDialog dialog.ThemeDialog
|
||||
|
||||
showArgumentsDialog bool
|
||||
argumentsDialog dialog.ArgumentsDialogCmp
|
||||
showMultiArgumentsDialog bool
|
||||
multiArgumentsDialog dialog.MultiArgumentsDialogCmp
|
||||
}
|
||||
|
||||
func (a appModel) Init() tea.Cmd {
|
||||
@@ -196,7 +196,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return a.updateAllPages(msg)
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
msg.Height -= 1 // Make space for the status bar
|
||||
msg.Height -= 2 // Make space for the status bar
|
||||
a.width, a.height = msg.Width, msg.Height
|
||||
|
||||
s, _ := a.status.Update(msg)
|
||||
@@ -226,11 +226,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
a.initDialog.SetSize(msg.Width, msg.Height)
|
||||
|
||||
if a.showArgumentsDialog {
|
||||
a.argumentsDialog.SetSize(msg.Width, msg.Height)
|
||||
args, argsCmd := a.argumentsDialog.Update(msg)
|
||||
a.argumentsDialog = args.(dialog.ArgumentsDialogCmp)
|
||||
cmds = append(cmds, argsCmd, a.argumentsDialog.Init())
|
||||
if a.showMultiArgumentsDialog {
|
||||
a.multiArgumentsDialog.SetSize(msg.Width, msg.Height)
|
||||
args, argsCmd := a.multiArgumentsDialog.Update(msg)
|
||||
a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
|
||||
cmds = append(cmds, argsCmd, a.multiArgumentsDialog.Init())
|
||||
}
|
||||
|
||||
return a, tea.Batch(cmds...)
|
||||
@@ -346,33 +346,39 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
status.Info("Command selected: " + msg.Command.Title)
|
||||
return a, nil
|
||||
|
||||
case dialog.ShowArgumentsDialogMsg:
|
||||
// Show arguments dialog
|
||||
a.argumentsDialog = dialog.NewArgumentsDialogCmp(msg.CommandID, msg.Content)
|
||||
a.showArgumentsDialog = true
|
||||
return a, a.argumentsDialog.Init()
|
||||
case dialog.ShowMultiArgumentsDialogMsg:
|
||||
// Show multi-arguments dialog
|
||||
a.multiArgumentsDialog = dialog.NewMultiArgumentsDialogCmp(msg.CommandID, msg.Content, msg.ArgNames)
|
||||
a.showMultiArgumentsDialog = true
|
||||
return a, a.multiArgumentsDialog.Init()
|
||||
|
||||
case dialog.CloseArgumentsDialogMsg:
|
||||
// Close arguments dialog
|
||||
a.showArgumentsDialog = false
|
||||
case dialog.CloseMultiArgumentsDialogMsg:
|
||||
// Close multi-arguments dialog
|
||||
a.showMultiArgumentsDialog = false
|
||||
|
||||
// If submitted, replace $ARGUMENTS and run the command
|
||||
// If submitted, replace all named arguments and run the command
|
||||
if msg.Submit {
|
||||
// Replace $ARGUMENTS with the provided arguments
|
||||
content := strings.ReplaceAll(msg.Content, "$ARGUMENTS", msg.Arguments)
|
||||
content := msg.Content
|
||||
|
||||
// Replace each named argument with its value
|
||||
for name, value := range msg.Args {
|
||||
placeholder := "$" + name
|
||||
content = strings.ReplaceAll(content, placeholder, value)
|
||||
}
|
||||
|
||||
// Execute the command with arguments
|
||||
return a, util.CmdHandler(dialog.CommandRunCustomMsg{
|
||||
Content: content,
|
||||
Args: msg.Args,
|
||||
})
|
||||
}
|
||||
return a, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
// If arguments dialog is open, let it handle the key press first
|
||||
if a.showArgumentsDialog {
|
||||
args, cmd := a.argumentsDialog.Update(msg)
|
||||
a.argumentsDialog = args.(dialog.ArgumentsDialogCmp)
|
||||
// If multi-arguments dialog is open, let it handle the key press first
|
||||
if a.showMultiArgumentsDialog {
|
||||
args, cmd := a.multiArgumentsDialog.Update(msg)
|
||||
a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
@@ -395,8 +401,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if a.showModelDialog {
|
||||
a.showModelDialog = false
|
||||
}
|
||||
if a.showArgumentsDialog {
|
||||
a.showArgumentsDialog = false
|
||||
if a.showMultiArgumentsDialog {
|
||||
a.showMultiArgumentsDialog = false
|
||||
}
|
||||
return a, nil
|
||||
case key.Matches(msg, keys.SwitchSession):
|
||||
@@ -800,8 +806,8 @@ func (a appModel) View() string {
|
||||
)
|
||||
}
|
||||
|
||||
if a.showArgumentsDialog {
|
||||
overlay := a.argumentsDialog.View()
|
||||
if a.showMultiArgumentsDialog {
|
||||
overlay := a.multiArgumentsDialog.View()
|
||||
row := lipgloss.Height(appView) / 2
|
||||
row -= lipgloss.Height(overlay) / 2
|
||||
col := lipgloss.Width(appView) / 2
|
||||
|
||||
@@ -12,63 +12,74 @@
|
||||
"model": {
|
||||
"description": "Model ID for the agent",
|
||||
"enum": [
|
||||
"azure.o1-mini",
|
||||
"openrouter.gemini-2.5-flash",
|
||||
"claude-3-haiku",
|
||||
"o1-mini",
|
||||
"qwen-qwq",
|
||||
"llama-3.3-70b-versatile",
|
||||
"openrouter.claude-3.5-sonnet",
|
||||
"o3-mini",
|
||||
"o4-mini",
|
||||
"gpt-4.1",
|
||||
"azure.o3-mini",
|
||||
"openrouter.gpt-4.1-nano",
|
||||
"openrouter.gpt-4o",
|
||||
"gemini-2.5",
|
||||
"azure.gpt-4o",
|
||||
"azure.gpt-4o-mini",
|
||||
"claude-3.7-sonnet",
|
||||
"azure.gpt-4.1-nano",
|
||||
"openrouter.o1",
|
||||
"openrouter.claude-3-haiku",
|
||||
"bedrock.claude-3.7-sonnet",
|
||||
"gemini-2.5-flash",
|
||||
"azure.o3",
|
||||
"openrouter.gemini-2.5",
|
||||
"openrouter.o3",
|
||||
"openrouter.o3-mini",
|
||||
"openrouter.gpt-4.1-mini",
|
||||
"openrouter.gpt-4.5-preview",
|
||||
"openrouter.gpt-4o-mini",
|
||||
"gpt-4.1-mini",
|
||||
"meta-llama/llama-4-scout-17b-16e-instruct",
|
||||
"openrouter.o1-mini",
|
||||
"gpt-4.5-preview",
|
||||
"o3",
|
||||
"openrouter.claude-3.5-haiku",
|
||||
"claude-3-opus",
|
||||
"o1-pro",
|
||||
"gemini-2.0-flash",
|
||||
"azure.o4-mini",
|
||||
"openrouter.o4-mini",
|
||||
"claude-3.5-sonnet",
|
||||
"meta-llama/llama-4-maverick-17b-128e-instruct",
|
||||
"azure.o1",
|
||||
"openrouter.gpt-4.1",
|
||||
"openrouter.o1-pro",
|
||||
"gpt-4.1-nano",
|
||||
"azure.gpt-4.5-preview",
|
||||
"openrouter.claude-3-opus",
|
||||
"gpt-4o-mini",
|
||||
"meta-llama/llama-4-scout-17b-16e-instruct",
|
||||
"openrouter.gpt-4o",
|
||||
"o1-pro",
|
||||
"claude-3-haiku",
|
||||
"o1",
|
||||
"deepseek-r1-distill-llama-70b",
|
||||
"azure.gpt-4.1",
|
||||
"gpt-4o",
|
||||
"azure.gpt-4.1-mini",
|
||||
"openrouter.claude-3.7-sonnet",
|
||||
"gemini-2.5-flash",
|
||||
"vertexai.gemini-2.5-flash",
|
||||
"claude-3.5-haiku",
|
||||
"gemini-2.0-flash-lite"
|
||||
"gpt-4o-mini",
|
||||
"o3-mini",
|
||||
"gpt-4.5-preview",
|
||||
"azure.gpt-4o",
|
||||
"azure.o4-mini",
|
||||
"openrouter.claude-3.5-sonnet",
|
||||
"gpt-4o",
|
||||
"o3",
|
||||
"gpt-4.1-mini",
|
||||
"llama-3.3-70b-versatile",
|
||||
"azure.gpt-4o-mini",
|
||||
"gpt-4.1-nano",
|
||||
"o4-mini",
|
||||
"qwen-qwq",
|
||||
"openrouter.claude-3.5-haiku",
|
||||
"openrouter.qwen-3-14b",
|
||||
"vertexai.gemini-2.5",
|
||||
"gemini-2.5",
|
||||
"azure.gpt-4.1-nano",
|
||||
"openrouter.o1-mini",
|
||||
"openrouter.qwen-3-30b",
|
||||
"claude-3.7-sonnet",
|
||||
"claude-3.5-sonnet",
|
||||
"gemini-2.0-flash",
|
||||
"meta-llama/llama-4-maverick-17b-128e-instruct",
|
||||
"openrouter.o3-mini",
|
||||
"openrouter.o4-mini",
|
||||
"openrouter.gpt-4.1-mini",
|
||||
"openrouter.o1",
|
||||
"o1-mini",
|
||||
"azure.gpt-4.1-mini",
|
||||
"openrouter.o1-pro",
|
||||
"grok-3-beta",
|
||||
"grok-3-mini-fast-beta",
|
||||
"openrouter.claude-3.7-sonnet",
|
||||
"openrouter.claude-3-opus",
|
||||
"openrouter.qwen-3-235b",
|
||||
"openrouter.gpt-4.1-nano",
|
||||
"bedrock.claude-3.7-sonnet",
|
||||
"openrouter.qwen-3-8b",
|
||||
"claude-3-opus",
|
||||
"azure.o1-mini",
|
||||
"deepseek-r1-distill-llama-70b",
|
||||
"gemini-2.0-flash-lite",
|
||||
"openrouter.qwen-3-32b",
|
||||
"openrouter.gpt-4.5-preview",
|
||||
"grok-3-mini-beta",
|
||||
"grok-3-fast-beta",
|
||||
"azure.o3-mini",
|
||||
"openrouter.claude-3-haiku",
|
||||
"azure.gpt-4.1",
|
||||
"azure.o1",
|
||||
"azure.o3",
|
||||
"azure.gpt-4.5-preview",
|
||||
"openrouter.gemini-2.5-flash",
|
||||
"openrouter.gpt-4o-mini",
|
||||
"openrouter.gemini-2.5"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
@@ -102,63 +113,74 @@
|
||||
"model": {
|
||||
"description": "Model ID for the agent",
|
||||
"enum": [
|
||||
"azure.o1-mini",
|
||||
"openrouter.gemini-2.5-flash",
|
||||
"claude-3-haiku",
|
||||
"o1-mini",
|
||||
"qwen-qwq",
|
||||
"llama-3.3-70b-versatile",
|
||||
"openrouter.claude-3.5-sonnet",
|
||||
"o3-mini",
|
||||
"o4-mini",
|
||||
"gpt-4.1",
|
||||
"azure.o3-mini",
|
||||
"openrouter.gpt-4.1-nano",
|
||||
"openrouter.gpt-4o",
|
||||
"gemini-2.5",
|
||||
"azure.gpt-4o",
|
||||
"azure.gpt-4o-mini",
|
||||
"claude-3.7-sonnet",
|
||||
"azure.gpt-4.1-nano",
|
||||
"openrouter.o1",
|
||||
"openrouter.claude-3-haiku",
|
||||
"bedrock.claude-3.7-sonnet",
|
||||
"gemini-2.5-flash",
|
||||
"azure.o3",
|
||||
"openrouter.gemini-2.5",
|
||||
"openrouter.o3",
|
||||
"openrouter.o3-mini",
|
||||
"openrouter.gpt-4.1-mini",
|
||||
"openrouter.gpt-4.5-preview",
|
||||
"openrouter.gpt-4o-mini",
|
||||
"gpt-4.1-mini",
|
||||
"meta-llama/llama-4-scout-17b-16e-instruct",
|
||||
"openrouter.o1-mini",
|
||||
"gpt-4.5-preview",
|
||||
"o3",
|
||||
"openrouter.claude-3.5-haiku",
|
||||
"claude-3-opus",
|
||||
"o1-pro",
|
||||
"gemini-2.0-flash",
|
||||
"azure.o4-mini",
|
||||
"openrouter.o4-mini",
|
||||
"claude-3.5-sonnet",
|
||||
"meta-llama/llama-4-maverick-17b-128e-instruct",
|
||||
"azure.o1",
|
||||
"openrouter.gpt-4.1",
|
||||
"openrouter.o1-pro",
|
||||
"gpt-4.1-nano",
|
||||
"azure.gpt-4.5-preview",
|
||||
"openrouter.claude-3-opus",
|
||||
"gpt-4o-mini",
|
||||
"meta-llama/llama-4-scout-17b-16e-instruct",
|
||||
"openrouter.gpt-4o",
|
||||
"o1-pro",
|
||||
"claude-3-haiku",
|
||||
"o1",
|
||||
"deepseek-r1-distill-llama-70b",
|
||||
"azure.gpt-4.1",
|
||||
"gpt-4o",
|
||||
"azure.gpt-4.1-mini",
|
||||
"openrouter.claude-3.7-sonnet",
|
||||
"gemini-2.5-flash",
|
||||
"vertexai.gemini-2.5-flash",
|
||||
"claude-3.5-haiku",
|
||||
"gemini-2.0-flash-lite"
|
||||
"gpt-4o-mini",
|
||||
"o3-mini",
|
||||
"gpt-4.5-preview",
|
||||
"azure.gpt-4o",
|
||||
"azure.o4-mini",
|
||||
"openrouter.claude-3.5-sonnet",
|
||||
"gpt-4o",
|
||||
"o3",
|
||||
"gpt-4.1-mini",
|
||||
"llama-3.3-70b-versatile",
|
||||
"azure.gpt-4o-mini",
|
||||
"gpt-4.1-nano",
|
||||
"o4-mini",
|
||||
"qwen-qwq",
|
||||
"openrouter.claude-3.5-haiku",
|
||||
"openrouter.qwen-3-14b",
|
||||
"vertexai.gemini-2.5",
|
||||
"gemini-2.5",
|
||||
"azure.gpt-4.1-nano",
|
||||
"openrouter.o1-mini",
|
||||
"openrouter.qwen-3-30b",
|
||||
"claude-3.7-sonnet",
|
||||
"claude-3.5-sonnet",
|
||||
"gemini-2.0-flash",
|
||||
"meta-llama/llama-4-maverick-17b-128e-instruct",
|
||||
"openrouter.o3-mini",
|
||||
"openrouter.o4-mini",
|
||||
"openrouter.gpt-4.1-mini",
|
||||
"openrouter.o1",
|
||||
"o1-mini",
|
||||
"azure.gpt-4.1-mini",
|
||||
"openrouter.o1-pro",
|
||||
"grok-3-beta",
|
||||
"grok-3-mini-fast-beta",
|
||||
"openrouter.claude-3.7-sonnet",
|
||||
"openrouter.claude-3-opus",
|
||||
"openrouter.qwen-3-235b",
|
||||
"openrouter.gpt-4.1-nano",
|
||||
"bedrock.claude-3.7-sonnet",
|
||||
"openrouter.qwen-3-8b",
|
||||
"claude-3-opus",
|
||||
"azure.o1-mini",
|
||||
"deepseek-r1-distill-llama-70b",
|
||||
"gemini-2.0-flash-lite",
|
||||
"openrouter.qwen-3-32b",
|
||||
"openrouter.gpt-4.5-preview",
|
||||
"grok-3-mini-beta",
|
||||
"grok-3-fast-beta",
|
||||
"azure.o3-mini",
|
||||
"openrouter.claude-3-haiku",
|
||||
"azure.gpt-4.1",
|
||||
"azure.o1",
|
||||
"azure.o3",
|
||||
"azure.gpt-4.5-preview",
|
||||
"openrouter.gemini-2.5-flash",
|
||||
"openrouter.gpt-4o-mini",
|
||||
"openrouter.gemini-2.5"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
@@ -341,7 +363,8 @@
|
||||
"groq",
|
||||
"openrouter",
|
||||
"bedrock",
|
||||
"azure"
|
||||
"azure",
|
||||
"vertexai"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
BIN
screenshot.png
Normal file
BIN
screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Reference in New Issue
Block a user