Compare commits

..

22 Commits

Author SHA1 Message Date
adamdottv
c9cca48d08 fix: layout 2025-05-15 15:57:15 -05:00
adamdottv
3944930fc0 chore: cleanup 2025-05-15 15:45:22 -05:00
adamdottv
825c0b64af fix: build 2025-05-15 14:49:30 -05:00
adamdottv
d7af7dd3fe fix: redundant status msg 2025-05-15 14:42:10 -05:00
adamdottv
b112216241 fix: build 2025-05-15 13:36:58 -05:00
mineo
87237b6462 feat: support VertexAI provider (#153)
* support: vertexai

fix

fix

set default for vertexai

added comment

fix

fix

* create schema

* fix README.md

* fix order

* added pupularity

* set tools if tools is exists

restore commentout

* fix comment

* set summarizer model
2025-05-15 13:35:06 -05:00
phantomreactor
5f5f9dad87 add support to preview webp images and paste file paths 2025-05-15 12:55:36 -05:00
adamdottv
aa8b3ce1ee fix: init ordering 2025-05-15 12:52:42 -05:00
adamdottv
a65e593ab4 feat: batch tool 2025-05-15 12:44:16 -05:00
adamdottv
5d9058eb74 fix: tui height 2025-05-15 12:18:28 -05:00
adamdottv
a850320fad fix: don't limit session logs 2025-05-15 12:05:26 -05:00
adamdottv
ddbb217d0d feat: better status bar 2025-05-15 12:04:15 -05:00
Ed Zynda
ab150be7c3 feat: Support named arguments in custom commands (#158)
* Allow multiple named args

* fix: Fix styling in multi-arguments dialog

* Remove old unused modal

* Focus on only one input at a time
2025-05-15 09:43:33 -05:00
Adictya
a203fb8ccc fix(complete-module): change completion start key to slash 2025-05-15 08:29:54 -05:00
Adictya
acc084c9ea chore(complete-module): lint 2025-05-15 08:29:54 -05:00
Adictya
3ee213081e fix(complete-module): logging 2025-05-15 08:29:54 -05:00
Adictya
15bf40bc10 feat(complete-module): add completions logic, dialog and providers 2025-05-15 08:29:54 -05:00
adamdottv
a33e3e25b6 chore: cleanup dead code 2025-05-15 07:19:17 -05:00
adamdottv
658faab2bf chore: update icon in readme 2025-05-15 07:03:58 -05:00
rekram1-node
797045ee29 feat: add configuration persistence for model selections 2025-05-14 16:32:05 -05:00
adamdottv
c8f8d67a88 feat: codeAction tool 2025-05-14 14:57:47 -05:00
Dax Raad
182e32e4f7 add screenshot 2025-05-14 15:50:25 -04:00
43 changed files with 2637 additions and 684 deletions

View File

@@ -1,4 +1,6 @@
# OpenCode
# OpenCode
![OpenCode Terminal UI](screenshot.png)
> **⚠️ Early Development Notice:** This project is in early development and is not yet ready for production use. Features may change, break, or be incomplete. Use at your own risk.
@@ -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

View File

@@ -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
View File

@@ -19,6 +19,7 @@ require (
github.com/fsnotify/fsnotify v1.8.0
github.com/go-logfmt/logfmt v0.6.0
github.com/google/uuid v1.6.0
github.com/lithammer/fuzzysearch v1.1.8
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
github.com/mark3labs/mcp-go v0.17.0
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6

2
go.sum
View File

@@ -144,6 +144,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms=
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231/go.mod h1:S5etECMx+sZnW0Gm100Ma9J1PgVCTgNyFaqGu2b08b4=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=

View File

@@ -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{},

View File

@@ -0,0 +1,191 @@
package completions
import (
"bytes"
"fmt"
"os/exec"
"path/filepath"
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/sst/opencode/internal/fileutil"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/tui/components/dialog"
)
type filesAndFoldersContextGroup struct {
prefix string
}
func (cg *filesAndFoldersContextGroup) GetId() string {
return cg.prefix
}
func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI {
return dialog.NewCompletionItem(dialog.CompletionItem{
Title: "Files & Folders",
Value: "files",
})
}
func processNullTerminatedOutput(outputBytes []byte) []string {
if len(outputBytes) > 0 && outputBytes[len(outputBytes)-1] == 0 {
outputBytes = outputBytes[:len(outputBytes)-1]
}
if len(outputBytes) == 0 {
return []string{}
}
split := bytes.Split(outputBytes, []byte{0})
matches := make([]string, 0, len(split))
for _, p := range split {
if len(p) == 0 {
continue
}
path := string(p)
path = filepath.Join(".", path)
if !fileutil.SkipHidden(path) {
matches = append(matches, path)
}
}
return matches
}
func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) {
cmdRg := fileutil.GetRgCmd("") // No glob pattern for this use case
cmdFzf := fileutil.GetFzfCmd(query)
var matches []string
// Case 1: Both rg and fzf available
if cmdRg != nil && cmdFzf != nil {
rgPipe, err := cmdRg.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("failed to get rg stdout pipe: %w", err)
}
defer rgPipe.Close()
cmdFzf.Stdin = rgPipe
var fzfOut bytes.Buffer
var fzfErr bytes.Buffer
cmdFzf.Stdout = &fzfOut
cmdFzf.Stderr = &fzfErr
if err := cmdFzf.Start(); err != nil {
return nil, fmt.Errorf("failed to start fzf: %w", err)
}
errRg := cmdRg.Run()
errFzf := cmdFzf.Wait()
if errRg != nil {
status.Warn(fmt.Sprintf("rg command failed during pipe: %v", errRg))
}
if errFzf != nil {
if exitErr, ok := errFzf.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return []string{}, nil // No matches from fzf
}
return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", errFzf, fzfErr.String())
}
matches = processNullTerminatedOutput(fzfOut.Bytes())
// Case 2: Only rg available
} else if cmdRg != nil {
status.Debug("Using Ripgrep with fuzzy match fallback for file completions")
var rgOut bytes.Buffer
var rgErr bytes.Buffer
cmdRg.Stdout = &rgOut
cmdRg.Stderr = &rgErr
if err := cmdRg.Run(); err != nil {
return nil, fmt.Errorf("rg command failed: %w\nStderr: %s", err, rgErr.String())
}
allFiles := processNullTerminatedOutput(rgOut.Bytes())
matches = fuzzy.Find(query, allFiles)
// Case 3: Only fzf available
} else if cmdFzf != nil {
status.Debug("Using FZF with doublestar fallback for file completions")
files, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0)
if err != nil {
return nil, fmt.Errorf("failed to list files for fzf: %w", err)
}
allFiles := make([]string, 0, len(files))
for _, file := range files {
if !fileutil.SkipHidden(file) {
allFiles = append(allFiles, file)
}
}
var fzfIn bytes.Buffer
for _, file := range allFiles {
fzfIn.WriteString(file)
fzfIn.WriteByte(0)
}
cmdFzf.Stdin = &fzfIn
var fzfOut bytes.Buffer
var fzfErr bytes.Buffer
cmdFzf.Stdout = &fzfOut
cmdFzf.Stderr = &fzfErr
if err := cmdFzf.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return []string{}, nil
}
return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", err, fzfErr.String())
}
matches = processNullTerminatedOutput(fzfOut.Bytes())
// Case 4: Fallback to doublestar with fuzzy match
} else {
status.Debug("Using doublestar with fuzzy match for file completions")
allFiles, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0)
if err != nil {
return nil, fmt.Errorf("failed to glob files: %w", err)
}
filteredFiles := make([]string, 0, len(allFiles))
for _, file := range allFiles {
if !fileutil.SkipHidden(file) {
filteredFiles = append(filteredFiles, file)
}
}
matches = fuzzy.Find(query, filteredFiles)
}
return matches, nil
}
func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
matches, err := cg.getFiles(query)
if err != nil {
return nil, err
}
items := make([]dialog.CompletionItemI, 0, len(matches))
for _, file := range matches {
item := dialog.NewCompletionItem(dialog.CompletionItem{
Title: file,
Value: file,
})
items = append(items, item)
}
return items, nil
}
func NewFileAndFolderContextGroup() dialog.CompletionProvider {
return &filesAndFoldersContextGroup{
prefix: "file",
}
}

View File

@@ -56,7 +56,7 @@ type Provider struct {
// Data defines storage configuration.
type Data struct {
Directory string `json:"directory"`
Directory string `json:"directory,omitempty"`
}
// LSPConfig defines configuration for Language Server Protocol integration.
@@ -80,7 +80,7 @@ type Config struct {
MCPServers map[string]MCPServer `json:"mcpServers,omitempty"`
Providers map[models.ModelProvider]Provider `json:"providers,omitempty"`
LSP map[string]LSPConfig `json:"lsp,omitempty"`
Agents map[AgentName]Agent `json:"agents"`
Agents map[AgentName]Agent `json:"agents,omitempty"`
Debug bool `json:"debug,omitempty"`
DebugLSP bool `json:"debugLSP,omitempty"`
ContextPaths []string `json:"contextPaths,omitempty"`
@@ -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
})
}

View File

@@ -0,0 +1,163 @@
package fileutil
import (
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/bmatcuk/doublestar/v4"
"github.com/sst/opencode/internal/status"
)
var (
rgPath string
fzfPath string
)
func Init() {
var err error
rgPath, err = exec.LookPath("rg")
if err != nil {
status.Warn("Ripgrep (rg) not found in $PATH. Some features might be limited or slower.")
rgPath = ""
}
fzfPath, err = exec.LookPath("fzf")
if err != nil {
status.Warn("FZF not found in $PATH. Some features might be limited or slower.")
fzfPath = ""
}
}
func GetRgCmd(globPattern string) *exec.Cmd {
if rgPath == "" {
return nil
}
rgArgs := []string{
"--files",
"-L",
"--null",
}
if globPattern != "" {
if !filepath.IsAbs(globPattern) && !strings.HasPrefix(globPattern, "/") {
globPattern = "/" + globPattern
}
rgArgs = append(rgArgs, "--glob", globPattern)
}
cmd := exec.Command(rgPath, rgArgs...)
cmd.Dir = "."
return cmd
}
func GetFzfCmd(query string) *exec.Cmd {
if fzfPath == "" {
return nil
}
fzfArgs := []string{
"--filter",
query,
"--read0",
"--print0",
}
cmd := exec.Command(fzfPath, fzfArgs...)
cmd.Dir = "."
return cmd
}
type FileInfo struct {
Path string
ModTime time.Time
}
func SkipHidden(path string) bool {
// Check for hidden files (starting with a dot)
base := filepath.Base(path)
if base != "." && strings.HasPrefix(base, ".") {
return true
}
commonIgnoredDirs := map[string]bool{
".opencode": true,
"node_modules": true,
"vendor": true,
"dist": true,
"build": true,
"target": true,
".git": true,
".idea": true,
".vscode": true,
"__pycache__": true,
"bin": true,
"obj": true,
"out": true,
"coverage": true,
"tmp": true,
"temp": true,
"logs": true,
"generated": true,
"bower_components": true,
"jspm_packages": true,
}
parts := strings.Split(path, string(os.PathSeparator))
for _, part := range parts {
if commonIgnoredDirs[part] {
return true
}
}
return false
}
func GlobWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) {
fsys := os.DirFS(searchPath)
relPattern := strings.TrimPrefix(pattern, "/")
var matches []FileInfo
err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
if d.IsDir() {
return nil
}
if SkipHidden(path) {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
absPath := path
if !strings.HasPrefix(absPath, searchPath) && searchPath != "." {
absPath = filepath.Join(searchPath, absPath)
} else if !strings.HasPrefix(absPath, "/") && searchPath == "." {
absPath = filepath.Join(searchPath, absPath) // Ensure relative paths are joined correctly
}
matches = append(matches, FileInfo{Path: absPath, ModTime: info.ModTime()})
if limit > 0 && len(matches) >= limit*2 {
return fs.SkipAll
}
return nil
})
if err != nil {
return nil, false, fmt.Errorf("glob walk error: %w", err)
}
sort.Slice(matches, func(i, j int) bool {
return matches[i].ModTime.After(matches[j].ModTime)
})
truncated := false
if limit > 0 && len(matches) > limit {
matches = matches[:limit]
truncated = true
}
results := make([]string, len(matches))
for i, m := range matches {
results[i] = m.Path
}
return results, truncated, nil
}

View File

@@ -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
}

View File

@@ -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)
}

View 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,
},
}

View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -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
}
}

View File

@@ -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"),

View 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
View 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), &params); 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
}

View 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")
})
}

View File

@@ -5,16 +5,14 @@ import (
"context"
"encoding/json"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/bmatcuk/doublestar/v4"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/fileutil"
"github.com/sst/opencode/internal/status"
)
const (
@@ -55,11 +53,6 @@ TIPS:
- Always check if results are truncated and refine your search pattern if needed`
)
type fileInfo struct {
path string
modTime time.Time
}
type GlobParams struct {
Pattern string `json:"pattern"`
Path string `json:"path"`
@@ -134,41 +127,20 @@ func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
}
func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
matches, err := globWithRipgrep(pattern, searchPath, limit)
if err == nil {
return matches, len(matches) >= limit, nil
cmdRg := fileutil.GetRgCmd(pattern)
if cmdRg != nil {
cmdRg.Dir = searchPath
matches, err := runRipgrep(cmdRg, searchPath, limit)
if err == nil {
return matches, len(matches) >= limit && limit > 0, nil
}
status.Warn(fmt.Sprintf("Ripgrep execution failed: %v. Falling back to doublestar.", err))
}
return globWithDoublestar(pattern, searchPath, limit)
return fileutil.GlobWithDoublestar(pattern, searchPath, limit)
}
func globWithRipgrep(
pattern, searchRoot string,
limit int,
) ([]string, error) {
if searchRoot == "" {
searchRoot = "."
}
rgBin, err := exec.LookPath("rg")
if err != nil {
return nil, fmt.Errorf("ripgrep not found in $PATH: %w", err)
}
if !filepath.IsAbs(pattern) && !strings.HasPrefix(pattern, "/") {
pattern = "/" + pattern
}
args := []string{
"--files",
"--null",
"--glob", pattern,
"-L",
}
cmd := exec.Command(rgBin, args...)
cmd.Dir = searchRoot
func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) {
out, err := cmd.CombinedOutput()
if err != nil {
if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
@@ -182,117 +154,22 @@ func globWithRipgrep(
if len(p) == 0 {
continue
}
abs := filepath.Join(searchRoot, string(p))
if skipHidden(abs) {
absPath := string(p)
if !filepath.IsAbs(absPath) {
absPath = filepath.Join(searchRoot, absPath)
}
if fileutil.SkipHidden(absPath) {
continue
}
matches = append(matches, abs)
matches = append(matches, absPath)
}
sort.SliceStable(matches, func(i, j int) bool {
return len(matches[i]) < len(matches[j])
})
if len(matches) > limit {
if limit > 0 && len(matches) > limit {
matches = matches[:limit]
}
return matches, nil
}
func globWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) {
fsys := os.DirFS(searchPath)
relPattern := strings.TrimPrefix(pattern, "/")
var matches []fileInfo
err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
if d.IsDir() {
return nil
}
if skipHidden(path) {
return nil
}
info, err := d.Info()
if err != nil {
return nil // Skip files we can't access
}
absPath := path // Restore absolute path
if !strings.HasPrefix(absPath, searchPath) {
absPath = filepath.Join(searchPath, absPath)
}
matches = append(matches, fileInfo{
path: absPath,
modTime: info.ModTime(),
})
if len(matches) >= limit*2 { // Collect more than needed for sorting
return fs.SkipAll
}
return nil
})
if err != nil {
return nil, false, fmt.Errorf("glob walk error: %w", err)
}
sort.Slice(matches, func(i, j int) bool {
return matches[i].modTime.After(matches[j].modTime)
})
truncated := len(matches) > limit
if truncated {
matches = matches[:limit]
}
results := make([]string, len(matches))
for i, m := range matches {
results[i] = m.path
}
return results, truncated, nil
}
func skipHidden(path string) bool {
// Check for hidden files (starting with a dot)
base := filepath.Base(path)
if base != "." && strings.HasPrefix(base, ".") {
return true
}
// List of commonly ignored directories in development projects
commonIgnoredDirs := map[string]bool{
"node_modules": true,
"vendor": true,
"dist": true,
"build": true,
"target": true,
".git": true,
".idea": true,
".vscode": true,
"__pycache__": true,
"bin": true,
"obj": true,
"out": true,
"coverage": true,
"tmp": true,
"temp": true,
"logs": true,
"generated": true,
"bower_components": true,
"jspm_packages": true,
}
// Check if any path component is in our ignore list
parts := strings.SplitSeq(path, string(os.PathSeparator))
for part := range parts {
if commonIgnoredDirs[part] {
return true
}
}
return false
}

View File

@@ -15,6 +15,7 @@ import (
"time"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/fileutil"
)
type GrepParams struct {
@@ -288,7 +289,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
return nil // Skip directories
}
if skipHidden(path) {
if fileutil.SkipHidden(path) {
return nil
}

View File

@@ -0,0 +1,350 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/lsp/protocol"
"github.com/sst/opencode/internal/lsp/util"
)
type CodeActionParams struct {
FilePath string `json:"file_path"`
Line int `json:"line"`
Column int `json:"column"`
EndLine int `json:"end_line,omitempty"`
EndColumn int `json:"end_column,omitempty"`
ActionID int `json:"action_id,omitempty"`
LspName string `json:"lsp_name,omitempty"`
}
type codeActionTool struct {
lspClients map[string]*lsp.Client
}
const (
CodeActionToolName = "codeAction"
codeActionDescription = `Get available code actions at a specific position or range in a file.
WHEN TO USE THIS TOOL:
- Use when you need to find available fixes or refactorings for code issues
- Helpful for resolving errors, warnings, or improving code quality
- Great for discovering automated code transformations
HOW TO USE:
- Provide the path to the file containing the code
- Specify the line number (1-based) where the action should be applied
- Specify the column number (1-based) where the action should be applied
- Optionally specify end_line and end_column to define a range
- Results show available code actions with their titles and kinds
TO EXECUTE A CODE ACTION:
- After getting the list of available actions, call the tool again with the same parameters
- Add action_id parameter with the number of the action you want to execute (e.g., 1 for the first action)
- Add lsp_name parameter with the name of the LSP server that provided the action
FEATURES:
- Finds quick fixes for errors and warnings
- Discovers available refactorings
- Shows code organization actions
- Returns detailed information about each action
- Can execute selected code actions
LIMITATIONS:
- Requires a functioning LSP server for the file type
- May not work for all code issues depending on LSP capabilities
- Results depend on the accuracy of the LSP server
TIPS:
- Use in conjunction with Diagnostics tool to find issues that can be fixed
- First call without action_id to see available actions, then call again with action_id to execute
`
)
func NewCodeActionTool(lspClients map[string]*lsp.Client) BaseTool {
return &codeActionTool{
lspClients,
}
}
func (b *codeActionTool) Info() ToolInfo {
return ToolInfo{
Name: CodeActionToolName,
Description: codeActionDescription,
Parameters: map[string]any{
"file_path": map[string]any{
"type": "string",
"description": "The path to the file containing the code",
},
"line": map[string]any{
"type": "integer",
"description": "The line number (1-based) where the action should be applied",
},
"column": map[string]any{
"type": "integer",
"description": "The column number (1-based) where the action should be applied",
},
"end_line": map[string]any{
"type": "integer",
"description": "The ending line number (1-based) for a range (optional)",
},
"end_column": map[string]any{
"type": "integer",
"description": "The ending column number (1-based) for a range (optional)",
},
"action_id": map[string]any{
"type": "integer",
"description": "The ID of the code action to execute (optional)",
},
"lsp_name": map[string]any{
"type": "string",
"description": "The name of the LSP server that provided the action (optional)",
},
},
Required: []string{"file_path", "line", "column"},
}
}
func (b *codeActionTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
var params CodeActionParams
if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
}
lsps := b.lspClients
if len(lsps) == 0 {
return NewTextResponse("\nLSP clients are still initializing. Code actions will be available once they're ready.\n"), nil
}
// Ensure file is open in LSP
notifyLspOpenFile(ctx, params.FilePath, lsps)
// Convert 1-based line/column to 0-based for LSP protocol
line := max(0, params.Line-1)
column := max(0, params.Column-1)
// Handle optional end line/column
endLine := line
endColumn := column
if params.EndLine > 0 {
endLine = max(0, params.EndLine-1)
}
if params.EndColumn > 0 {
endColumn = max(0, params.EndColumn-1)
}
// Check if we're executing a specific action
if params.ActionID > 0 && params.LspName != "" {
return executeCodeAction(ctx, params.FilePath, line, column, endLine, endColumn, params.ActionID, params.LspName, lsps)
}
// Otherwise, just list available actions
output := getCodeActions(ctx, params.FilePath, line, column, endLine, endColumn, lsps)
return NewTextResponse(output), nil
}
func getCodeActions(ctx context.Context, filePath string, line, column, endLine, endColumn int, lsps map[string]*lsp.Client) string {
var results []string
for lspName, client := range lsps {
// Create code action params
uri := fmt.Sprintf("file://%s", filePath)
codeActionParams := protocol.CodeActionParams{
TextDocument: protocol.TextDocumentIdentifier{
URI: protocol.DocumentUri(uri),
},
Range: protocol.Range{
Start: protocol.Position{
Line: uint32(line),
Character: uint32(column),
},
End: protocol.Position{
Line: uint32(endLine),
Character: uint32(endColumn),
},
},
Context: protocol.CodeActionContext{
// Request all kinds of code actions
Only: []protocol.CodeActionKind{
protocol.QuickFix,
protocol.Refactor,
protocol.RefactorExtract,
protocol.RefactorInline,
protocol.RefactorRewrite,
protocol.Source,
protocol.SourceOrganizeImports,
protocol.SourceFixAll,
},
},
}
// Get code actions
codeActions, err := client.CodeAction(ctx, codeActionParams)
if err != nil {
results = append(results, fmt.Sprintf("Error from %s: %s", lspName, err))
continue
}
if len(codeActions) == 0 {
results = append(results, fmt.Sprintf("No code actions found by %s", lspName))
continue
}
// Format the code actions
results = append(results, fmt.Sprintf("Code actions found by %s:", lspName))
for i, action := range codeActions {
actionInfo := formatCodeAction(action, i+1)
results = append(results, actionInfo)
}
}
if len(results) == 0 {
return "No code actions found at the specified position."
}
return strings.Join(results, "\n")
}
func formatCodeAction(action protocol.Or_Result_textDocument_codeAction_Item0_Elem, index int) string {
switch v := action.Value.(type) {
case protocol.CodeAction:
kind := "Unknown"
if v.Kind != "" {
kind = string(v.Kind)
}
var details []string
// Add edit information if available
if v.Edit != nil {
numChanges := 0
if v.Edit.Changes != nil {
numChanges = len(v.Edit.Changes)
}
if v.Edit.DocumentChanges != nil {
numChanges = len(v.Edit.DocumentChanges)
}
details = append(details, fmt.Sprintf("Edits: %d changes", numChanges))
}
// Add command information if available
if v.Command != nil {
details = append(details, fmt.Sprintf("Command: %s", v.Command.Title))
}
// Add diagnostics information if available
if v.Diagnostics != nil && len(v.Diagnostics) > 0 {
details = append(details, fmt.Sprintf("Fixes: %d diagnostics", len(v.Diagnostics)))
}
detailsStr := ""
if len(details) > 0 {
detailsStr = " (" + strings.Join(details, ", ") + ")"
}
return fmt.Sprintf(" %d. %s [%s]%s", index, v.Title, kind, detailsStr)
case protocol.Command:
return fmt.Sprintf(" %d. %s [Command]", index, v.Title)
}
return fmt.Sprintf(" %d. Unknown code action type", index)
}
func executeCodeAction(ctx context.Context, filePath string, line, column, endLine, endColumn, actionID int, lspName string, lsps map[string]*lsp.Client) (ToolResponse, error) {
client, ok := lsps[lspName]
if !ok {
return NewTextErrorResponse(fmt.Sprintf("LSP server '%s' not found", lspName)), nil
}
// Create code action params
uri := fmt.Sprintf("file://%s", filePath)
codeActionParams := protocol.CodeActionParams{
TextDocument: protocol.TextDocumentIdentifier{
URI: protocol.DocumentUri(uri),
},
Range: protocol.Range{
Start: protocol.Position{
Line: uint32(line),
Character: uint32(column),
},
End: protocol.Position{
Line: uint32(endLine),
Character: uint32(endColumn),
},
},
Context: protocol.CodeActionContext{
// Request all kinds of code actions
Only: []protocol.CodeActionKind{
protocol.QuickFix,
protocol.Refactor,
protocol.RefactorExtract,
protocol.RefactorInline,
protocol.RefactorRewrite,
protocol.Source,
protocol.SourceOrganizeImports,
protocol.SourceFixAll,
},
},
}
// Get code actions
codeActions, err := client.CodeAction(ctx, codeActionParams)
if err != nil {
return NewTextErrorResponse(fmt.Sprintf("Error getting code actions: %s", err)), nil
}
if len(codeActions) == 0 {
return NewTextErrorResponse("No code actions found"), nil
}
// Check if the requested action ID is valid
if actionID < 1 || actionID > len(codeActions) {
return NewTextErrorResponse(fmt.Sprintf("Invalid action ID: %d. Available actions: 1-%d", actionID, len(codeActions))), nil
}
// Get the selected action (adjust for 0-based index)
selectedAction := codeActions[actionID-1]
// Execute the action based on its type
switch v := selectedAction.Value.(type) {
case protocol.CodeAction:
// Apply workspace edit if available
if v.Edit != nil {
err := util.ApplyWorkspaceEdit(*v.Edit)
if err != nil {
return NewTextErrorResponse(fmt.Sprintf("Error applying edit: %s", err)), nil
}
}
// Execute command if available
if v.Command != nil {
_, err := client.ExecuteCommand(ctx, protocol.ExecuteCommandParams{
Command: v.Command.Command,
Arguments: v.Command.Arguments,
})
if err != nil {
return NewTextErrorResponse(fmt.Sprintf("Error executing command: %s", err)), nil
}
}
return NewTextResponse(fmt.Sprintf("Successfully executed code action: %s", v.Title)), nil
case protocol.Command:
// Execute the command
_, err := client.ExecuteCommand(ctx, protocol.ExecuteCommandParams{
Command: v.Command,
Arguments: v.Arguments,
})
if err != nil {
return NewTextErrorResponse(fmt.Sprintf("Error executing command: %s", err)), nil
}
return NewTextResponse(fmt.Sprintf("Successfully executed command: %s", v.Title)), nil
}
return NewTextErrorResponse("Unknown code action type"), nil
}

View File

@@ -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] {

View File

@@ -5,6 +5,7 @@ import (
"os"
"os/exec"
"slices"
"strings"
"unicode"
"github.com/charmbracelet/bubbles/key"
@@ -148,6 +149,11 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case dialog.ThemeChangedMsg:
m.textarea = CreateTextArea(&m.textarea)
case dialog.CompletionSelectedMsg:
existingValue := m.textarea.Value()
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
m.textarea.SetValue(modifiedValue)
return m, nil
case dialog.AttachmentAddedMsg:
if len(m.attachments) >= maxAttachments {
@@ -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
}

View File

@@ -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), &params)
filePath := removeWorkingDirPrefix(params.FilePath)
return renderParams(paramWidth, filePath)
case tools.BatchToolName:
var params tools.BatchParams
json.Unmarshal([]byte(toolCall.Input), &params)
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(

View File

@@ -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 {

View File

@@ -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

View File

@@ -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
}

View File

@@ -4,6 +4,7 @@ import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
utilComponents "github.com/sst/opencode/internal/tui/components/util"
"github.com/sst/opencode/internal/tui/layout"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
@@ -18,6 +19,33 @@ type Command struct {
Handler func(cmd Command) tea.Cmd
}
func (ci Command) Render(selected bool, width int) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
descStyle := baseStyle.Width(width).Foreground(t.TextMuted())
itemStyle := baseStyle.Width(width).
Foreground(t.Text()).
Background(t.Background())
if selected {
itemStyle = itemStyle.
Background(t.Primary()).
Foreground(t.Background()).
Bold(true)
descStyle = descStyle.
Background(t.Primary()).
Foreground(t.Background())
}
title := itemStyle.Padding(0, 1).Render(ci.Title)
if ci.Description != "" {
description := descStyle.Padding(0, 1).Render(ci.Description)
return lipgloss.JoinVertical(lipgloss.Left, title, description)
}
return title
}
// CommandSelectedMsg is sent when a command is selected
type CommandSelectedMsg struct {
Command Command
@@ -31,35 +59,20 @@ type CommandDialog interface {
tea.Model
layout.Bindings
SetCommands(commands []Command)
SetSelectedCommand(commandID string)
}
type commandDialogCmp struct {
commands []Command
selectedIdx int
width int
height int
selectedCommandID string
listView utilComponents.SimpleList[Command]
width int
height int
}
type commandKeyMap struct {
Up key.Binding
Down key.Binding
Enter key.Binding
Escape key.Binding
J key.Binding
K key.Binding
}
var commandKeys = commandKeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "previous command"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("↓", "next command"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select command"),
@@ -68,38 +81,22 @@ var commandKeys = commandKeyMap{
key.WithKeys("esc"),
key.WithHelp("esc", "close"),
),
J: key.NewBinding(
key.WithKeys("j"),
key.WithHelp("j", "next command"),
),
K: key.NewBinding(
key.WithKeys("k"),
key.WithHelp("k", "previous command"),
),
}
func (c *commandDialogCmp) Init() tea.Cmd {
return nil
return c.listView.Init()
}
func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, commandKeys.Up) || key.Matches(msg, commandKeys.K):
if c.selectedIdx > 0 {
c.selectedIdx--
}
return c, nil
case key.Matches(msg, commandKeys.Down) || key.Matches(msg, commandKeys.J):
if c.selectedIdx < len(c.commands)-1 {
c.selectedIdx++
}
return c, nil
case key.Matches(msg, commandKeys.Enter):
if len(c.commands) > 0 {
selectedItem, idx := c.listView.GetSelectedItem()
if idx != -1 {
return c, util.CmdHandler(CommandSelectedMsg{
Command: c.commands[c.selectedIdx],
Command: selectedItem,
})
}
case key.Matches(msg, commandKeys.Escape):
@@ -109,78 +106,35 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
c.width = msg.Width
c.height = msg.Height
}
return c, nil
u, cmd := c.listView.Update(msg)
c.listView = u.(utilComponents.SimpleList[Command])
cmds = append(cmds, cmd)
return c, tea.Batch(cmds...)
}
func (c *commandDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
if len(c.commands) == 0 {
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(40).
Render("No commands available")
}
maxWidth := 40
// Calculate max width needed for command titles
maxWidth := 40 // Minimum width
for _, cmd := range c.commands {
if len(cmd.Title) > maxWidth-4 { // Account for padding
commands := c.listView.GetItems()
for _, cmd := range commands {
if len(cmd.Title) > maxWidth-4 {
maxWidth = len(cmd.Title) + 4
}
if len(cmd.Description) > maxWidth-4 {
maxWidth = len(cmd.Description) + 4
}
}
// Limit height to avoid taking up too much screen space
maxVisibleCommands := min(10, len(c.commands))
// Build the command list
commandItems := make([]string, 0, maxVisibleCommands)
startIdx := 0
// If we have more commands than can be displayed, adjust the start index
if len(c.commands) > maxVisibleCommands {
// Center the selected item when possible
halfVisible := maxVisibleCommands / 2
if c.selectedIdx >= halfVisible && c.selectedIdx < len(c.commands)-halfVisible {
startIdx = c.selectedIdx - halfVisible
} else if c.selectedIdx >= len(c.commands)-halfVisible {
startIdx = len(c.commands) - maxVisibleCommands
}
}
endIdx := min(startIdx+maxVisibleCommands, len(c.commands))
for i := startIdx; i < endIdx; i++ {
cmd := c.commands[i]
itemStyle := baseStyle.Width(maxWidth)
descStyle := baseStyle.Width(maxWidth).Foreground(t.TextMuted())
if i == c.selectedIdx {
itemStyle = itemStyle.
Background(t.Primary()).
Foreground(t.Background()).
Bold(true)
descStyle = descStyle.
Background(t.Primary()).
Foreground(t.Background())
}
title := itemStyle.Padding(0, 1).Render(cmd.Title)
description := ""
if cmd.Description != "" {
description = descStyle.Padding(0, 1).Render(cmd.Description)
commandItems = append(commandItems, lipgloss.JoinVertical(lipgloss.Left, title, description))
} else {
commandItems = append(commandItems, title)
if len(cmd.Description) > maxWidth-4 {
maxWidth = len(cmd.Description) + 4
}
}
}
c.listView.SetMaxWidth(maxWidth)
title := baseStyle.
Foreground(t.Primary()).
Bold(true).
@@ -192,7 +146,7 @@ func (c *commandDialogCmp) View() string {
lipgloss.Left,
title,
baseStyle.Width(maxWidth).Render(""),
baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, commandItems...)),
baseStyle.Width(maxWidth).Render(c.listView.View()),
baseStyle.Width(maxWidth).Render(""),
)
@@ -209,41 +163,18 @@ func (c *commandDialogCmp) BindingKeys() []key.Binding {
}
func (c *commandDialogCmp) SetCommands(commands []Command) {
c.commands = commands
// If we have a selected command ID, find its index
if c.selectedCommandID != "" {
for i, cmd := range commands {
if cmd.ID == c.selectedCommandID {
c.selectedIdx = i
return
}
}
}
// Default to first command if selected not found
c.selectedIdx = 0
}
func (c *commandDialogCmp) SetSelectedCommand(commandID string) {
c.selectedCommandID = commandID
// Update the selected index if commands are already loaded
if len(c.commands) > 0 {
for i, cmd := range c.commands {
if cmd.ID == commandID {
c.selectedIdx = i
return
}
}
}
c.listView.SetItems(commands)
}
// NewCommandDialogCmp creates a new command selection dialog
func NewCommandDialogCmp() CommandDialog {
listView := utilComponents.NewSimpleList[Command](
[]Command{},
10,
"No commands available",
true,
)
return &commandDialogCmp{
commands: []Command{},
selectedIdx: 0,
selectedCommandID: "",
listView: listView,
}
}

View File

@@ -0,0 +1,263 @@
package dialog
import (
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/status"
utilComponents "github.com/sst/opencode/internal/tui/components/util"
"github.com/sst/opencode/internal/tui/layout"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/internal/tui/util"
)
type CompletionItem struct {
title string
Title string
Value string
}
type CompletionItemI interface {
utilComponents.SimpleListItem
GetValue() string
DisplayValue() string
}
func (ci *CompletionItem) Render(selected bool, width int) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
itemStyle := baseStyle.
Width(width).
Padding(0, 1)
if selected {
itemStyle = itemStyle.
Background(t.Background()).
Foreground(t.Primary()).
Bold(true)
}
title := itemStyle.Render(
ci.GetValue(),
)
return title
}
func (ci *CompletionItem) DisplayValue() string {
return ci.Title
}
func (ci *CompletionItem) GetValue() string {
return ci.Value
}
func NewCompletionItem(completionItem CompletionItem) CompletionItemI {
return &completionItem
}
type CompletionProvider interface {
GetId() string
GetEntry() CompletionItemI
GetChildEntries(query string) ([]CompletionItemI, error)
}
type CompletionSelectedMsg struct {
SearchString string
CompletionValue string
}
type CompletionDialogCompleteItemMsg struct {
Value string
}
type CompletionDialogCloseMsg struct{}
type CompletionDialog interface {
tea.Model
layout.Bindings
SetWidth(width int)
}
type completionDialogCmp struct {
query string
completionProvider CompletionProvider
width int
height int
pseudoSearchTextArea textarea.Model
listView utilComponents.SimpleList[CompletionItemI]
}
type completionDialogKeyMap struct {
Complete key.Binding
Cancel key.Binding
}
var completionDialogKeys = completionDialogKeyMap{
Complete: key.NewBinding(
key.WithKeys("tab", "enter"),
),
Cancel: key.NewBinding(
key.WithKeys(" ", "esc", "backspace"),
),
}
func (c *completionDialogCmp) Init() tea.Cmd {
return nil
}
func (c *completionDialogCmp) complete(item CompletionItemI) tea.Cmd {
value := c.pseudoSearchTextArea.Value()
if value == "" {
return nil
}
return tea.Batch(
util.CmdHandler(CompletionSelectedMsg{
SearchString: value,
CompletionValue: item.GetValue(),
}),
c.close(),
)
}
func (c *completionDialogCmp) close() tea.Cmd {
c.listView.SetItems([]CompletionItemI{})
c.pseudoSearchTextArea.Reset()
c.pseudoSearchTextArea.Blur()
return util.CmdHandler(CompletionDialogCloseMsg{})
}
func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
if c.pseudoSearchTextArea.Focused() {
if !key.Matches(msg, completionDialogKeys.Complete) {
var cmd tea.Cmd
c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
cmds = append(cmds, cmd)
var query string
query = c.pseudoSearchTextArea.Value()
if query != "" {
query = query[1:]
}
if query != c.query {
items, err := c.completionProvider.GetChildEntries(query)
if err != nil {
status.Error(err.Error())
}
c.listView.SetItems(items)
c.query = query
}
u, cmd := c.listView.Update(msg)
c.listView = u.(utilComponents.SimpleList[CompletionItemI])
cmds = append(cmds, cmd)
}
switch {
case key.Matches(msg, completionDialogKeys.Complete):
item, i := c.listView.GetSelectedItem()
if i == -1 {
return c, nil
}
cmd := c.complete(item)
return c, cmd
case key.Matches(msg, completionDialogKeys.Cancel):
// Only close on backspace when there are no characters left
if msg.String() != "backspace" || len(c.pseudoSearchTextArea.Value()) <= 0 {
return c, c.close()
}
}
return c, tea.Batch(cmds...)
} else {
items, err := c.completionProvider.GetChildEntries("")
if err != nil {
status.Error(err.Error())
}
c.listView.SetItems(items)
c.pseudoSearchTextArea.SetValue(msg.String())
return c, c.pseudoSearchTextArea.Focus()
}
case tea.WindowSizeMsg:
c.width = msg.Width
c.height = msg.Height
}
return c, tea.Batch(cmds...)
}
func (c *completionDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
maxWidth := 40
completions := c.listView.GetItems()
for _, cmd := range completions {
title := cmd.DisplayValue()
if len(title) > maxWidth-4 {
maxWidth = len(title) + 4
}
}
c.listView.SetMaxWidth(maxWidth)
return baseStyle.Padding(0, 0).
Border(lipgloss.NormalBorder()).
BorderBottom(false).
BorderRight(false).
BorderLeft(false).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(c.width).
Render(c.listView.View())
}
func (c *completionDialogCmp) SetWidth(width int) {
c.width = width
}
func (c *completionDialogCmp) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(completionDialogKeys)
}
func NewCompletionDialogCmp(completionProvider CompletionProvider) CompletionDialog {
ti := textarea.New()
items, err := completionProvider.GetChildEntries("")
if err != nil {
status.Error(err.Error())
}
li := utilComponents.NewSimpleList(
items,
7,
"No file matches found",
false,
)
return &completionDialogCmp{
query: "",
completionProvider: completionProvider,
pseudoSearchTextArea: ti,
listView: li,
}
}

View File

@@ -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
}

View 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)
}
}
}

View File

@@ -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))

View File

@@ -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{})

View File

@@ -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 {

View File

@@ -0,0 +1,159 @@
package utilComponents
import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/tui/layout"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
)
type SimpleListItem interface {
Render(selected bool, width int) string
}
type SimpleList[T SimpleListItem] interface {
tea.Model
layout.Bindings
SetMaxWidth(maxWidth int)
GetSelectedItem() (item T, idx int)
SetItems(items []T)
GetItems() []T
}
type simpleListCmp[T SimpleListItem] struct {
fallbackMsg string
items []T
selectedIdx int
maxWidth int
maxVisibleItems int
useAlphaNumericKeys bool
width int
height int
}
type simpleListKeyMap struct {
Up key.Binding
Down key.Binding
UpAlpha key.Binding
DownAlpha key.Binding
}
var simpleListKeys = simpleListKeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "previous list item"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("↓", "next list item"),
),
UpAlpha: key.NewBinding(
key.WithKeys("k"),
key.WithHelp("k", "previous list item"),
),
DownAlpha: key.NewBinding(
key.WithKeys("j"),
key.WithHelp("j", "next list item"),
),
}
func (c *simpleListCmp[T]) Init() tea.Cmd {
return nil
}
func (c *simpleListCmp[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, simpleListKeys.Up) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.UpAlpha)):
if c.selectedIdx > 0 {
c.selectedIdx--
}
return c, nil
case key.Matches(msg, simpleListKeys.Down) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.DownAlpha)):
if c.selectedIdx < len(c.items)-1 {
c.selectedIdx++
}
return c, nil
}
}
return c, nil
}
func (c *simpleListCmp[T]) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(simpleListKeys)
}
func (c *simpleListCmp[T]) GetSelectedItem() (T, int) {
if len(c.items) > 0 {
return c.items[c.selectedIdx], c.selectedIdx
}
var zero T
return zero, -1
}
func (c *simpleListCmp[T]) SetItems(items []T) {
c.selectedIdx = 0
c.items = items
}
func (c *simpleListCmp[T]) GetItems() []T {
return c.items
}
func (c *simpleListCmp[T]) SetMaxWidth(width int) {
c.maxWidth = width
}
func (c *simpleListCmp[T]) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
items := c.items
maxWidth := c.maxWidth
maxVisibleItems := min(c.maxVisibleItems, len(items))
startIdx := 0
if len(items) <= 0 {
return baseStyle.
Background(t.Background()).
Padding(0, 1).
Width(maxWidth).
Render(c.fallbackMsg)
}
if len(items) > maxVisibleItems {
halfVisible := maxVisibleItems / 2
if c.selectedIdx >= halfVisible && c.selectedIdx < len(items)-halfVisible {
startIdx = c.selectedIdx - halfVisible
} else if c.selectedIdx >= len(items)-halfVisible {
startIdx = len(items) - maxVisibleItems
}
}
endIdx := min(startIdx+maxVisibleItems, len(items))
listItems := make([]string, 0, maxVisibleItems)
for i := startIdx; i < endIdx; i++ {
item := items[i]
title := item.Render(i == c.selectedIdx, maxWidth)
listItems = append(listItems, title)
}
return lipgloss.JoinVertical(lipgloss.Left, listItems...)
}
func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) SimpleList[T] {
return &simpleListCmp[T]{
fallbackMsg: fallbackMsg,
items: items,
maxVisibleItems: maxVisibleItems,
useAlphaNumericKeys: useAlphaNumericKeys,
selectedIdx: 0,
}
}

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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),

View File

@@ -10,16 +10,3 @@ const (
SpinnerIcon string = "⟳"
DocumentIcon string = "🖼"
)
// CircledDigit returns the Unicode circled digit/number for 020.
// outofrange → "".
func CircledDigit(n int) string {
switch {
case n == 0:
return "\u24EA" // ⓪
case 1 <= n && n <= 20:
return string(rune(0x2460 + n - 1)) // ①–⑳
default:
return ""
}
}

View File

@@ -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

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB