Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
307982a099 | ||
|
|
ba416e787b | ||
|
|
b71cae63f1 | ||
|
|
c92f7c6630 | ||
|
|
4a444e9c9b | ||
|
|
623d132772 | ||
|
|
d127a1c4eb | ||
|
|
c9cca48d08 | ||
|
|
3944930fc0 | ||
|
|
825c0b64af | ||
|
|
d7af7dd3fe | ||
|
|
b112216241 | ||
|
|
87237b6462 | ||
|
|
5f5f9dad87 | ||
|
|
aa8b3ce1ee | ||
|
|
a65e593ab4 | ||
|
|
5d9058eb74 | ||
|
|
a850320fad | ||
|
|
ddbb217d0d | ||
|
|
ab150be7c3 |
+2
-1
@@ -42,4 +42,5 @@ Thumbs.db
|
||||
.env.local
|
||||
|
||||
.opencode/
|
||||
|
||||
# ignore locally built binary
|
||||
opencode*
|
||||
|
||||
@@ -21,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
|
||||
|
||||
@@ -73,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) |
|
||||
@@ -80,7 +83,6 @@ You can configure OpenCode using environment variables:
|
||||
| `AZURE_OPENAI_ENDPOINT` | For Azure OpenAI models |
|
||||
| `AZURE_OPENAI_API_KEY` | For Azure OpenAI models (optional when using Entra ID) |
|
||||
| `AZURE_OPENAI_API_VERSION` | For Azure OpenAI models |
|
||||
|
||||
### Configuration File Structure
|
||||
|
||||
```json
|
||||
@@ -134,6 +136,10 @@ You can configure OpenCode using environment variables:
|
||||
"command": "gopls"
|
||||
}
|
||||
},
|
||||
"shell": {
|
||||
"path": "/bin/zsh",
|
||||
"args": ["-l"]
|
||||
},
|
||||
"debug": false,
|
||||
"debugLSP": false
|
||||
}
|
||||
@@ -188,7 +194,12 @@ OpenCode supports a variety of AI models from different providers:
|
||||
- O3 family (o3, o3-mini)
|
||||
- O4 Mini
|
||||
|
||||
## Usage
|
||||
### Google Cloud VertexAI
|
||||
|
||||
- Gemini 2.5
|
||||
- Gemini 2.5 Flash
|
||||
|
||||
## Interactive Mode Usage
|
||||
|
||||
```bash
|
||||
# Start OpenCode
|
||||
@@ -201,13 +212,65 @@ opencode -d
|
||||
opencode -c /path/to/project
|
||||
```
|
||||
|
||||
## Non-interactive Prompt Mode
|
||||
|
||||
You can run OpenCode in non-interactive mode by passing a prompt directly as a command-line argument. This is useful for scripting, automation, or when you want a quick answer without launching the full TUI.
|
||||
|
||||
```bash
|
||||
# Run a single prompt and print the AI's response to the terminal
|
||||
opencode -p "Explain the use of context in Go"
|
||||
|
||||
# Get response in JSON format
|
||||
opencode -p "Explain the use of context in Go" -f json
|
||||
|
||||
# Run without showing the spinner
|
||||
opencode -p "Explain the use of context in Go" -q
|
||||
|
||||
# Enable verbose logging to stderr
|
||||
opencode -p "Explain the use of context in Go" --verbose
|
||||
|
||||
# Restrict the agent to only use specific tools
|
||||
opencode -p "Explain the use of context in Go" --allowedTools=view,ls,glob
|
||||
|
||||
# Prevent the agent from using specific tools
|
||||
opencode -p "Explain the use of context in Go" --excludedTools=bash,edit
|
||||
```
|
||||
|
||||
In this mode, OpenCode will process your prompt, print the result to standard output, and then exit. All permissions are auto-approved for the session.
|
||||
|
||||
### Tool Restrictions
|
||||
|
||||
You can control which tools the AI assistant has access to in non-interactive mode:
|
||||
|
||||
- `--allowedTools`: Comma-separated list of tools that the agent is allowed to use. Only these tools will be available.
|
||||
- `--excludedTools`: Comma-separated list of tools that the agent is not allowed to use. All other tools will be available.
|
||||
|
||||
These flags are mutually exclusive - you can use either `--allowedTools` or `--excludedTools`, but not both at the same time.
|
||||
|
||||
### Output Formats
|
||||
|
||||
OpenCode supports the following output formats in non-interactive mode:
|
||||
|
||||
| Format | Description |
|
||||
| ------ | -------------------------------------- |
|
||||
| `text` | Plain text output (default) |
|
||||
| `json` | Output wrapped in a JSON object |
|
||||
|
||||
The output format is implemented as a strongly-typed `OutputFormat` in the codebase, ensuring type safety and validation when processing outputs.
|
||||
|
||||
## Command-line Flags
|
||||
|
||||
| Flag | Short | Description |
|
||||
| --------- | ----- | ----------------------------- |
|
||||
| `--help` | `-h` | Display help information |
|
||||
| `--debug` | `-d` | Enable debug mode |
|
||||
| `--cwd` | `-c` | Set current working directory |
|
||||
| Flag | Short | Description |
|
||||
| ----------------- | ----- | ---------------------------------------------------------- |
|
||||
| `--help` | `-h` | Display help information |
|
||||
| `--debug` | `-d` | Enable debug mode |
|
||||
| `--cwd` | `-c` | Set current working directory |
|
||||
| `--prompt` | `-p` | Run a single prompt in non-interactive mode |
|
||||
| `--output-format` | `-f` | Output format for non-interactive mode (text, json) |
|
||||
| `--quiet` | `-q` | Hide spinner in non-interactive mode |
|
||||
| `--verbose` | | Display logs to stderr in non-interactive mode |
|
||||
| `--allowedTools` | | Restrict the agent to only use specified tools |
|
||||
| `--excludedTools` | | Prevent the agent from using specified tools |
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
@@ -373,6 +436,35 @@ You can define any of the following color keys in your `customTheme`:
|
||||
|
||||
You don't need to define all colors. Any undefined colors will fall back to the default "opencode" theme colors.
|
||||
|
||||
### Shell Configuration
|
||||
|
||||
OpenCode allows you to configure the shell used by the `bash` tool. By default, it uses:
|
||||
1. The shell specified in the config file (if provided)
|
||||
2. The shell from the `$SHELL` environment variable (if available)
|
||||
3. Falls back to `/bin/bash` if neither of the above is available
|
||||
|
||||
To configure a custom shell, add a `shell` section to your `.opencode.json` configuration file:
|
||||
|
||||
```json
|
||||
{
|
||||
"shell": {
|
||||
"path": "/bin/zsh",
|
||||
"args": ["-l"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can specify any shell executable and custom arguments:
|
||||
|
||||
```json
|
||||
{
|
||||
"shell": {
|
||||
"path": "/usr/bin/fish",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
OpenCode is built with a modular architecture:
|
||||
@@ -428,13 +520,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
|
||||
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"log/slog"
|
||||
|
||||
charmlog "github.com/charmbracelet/log"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/db"
|
||||
"github.com/sst/opencode/internal/format"
|
||||
"github.com/sst/opencode/internal/llm/agent"
|
||||
"github.com/sst/opencode/internal/llm/tools"
|
||||
"github.com/sst/opencode/internal/message"
|
||||
"github.com/sst/opencode/internal/permission"
|
||||
"github.com/sst/opencode/internal/tui/components/spinner"
|
||||
"github.com/sst/opencode/internal/tui/theme"
|
||||
)
|
||||
|
||||
// syncWriter is a thread-safe writer that prevents interleaved output
|
||||
type syncWriter struct {
|
||||
w io.Writer
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Write implements io.Writer
|
||||
func (sw *syncWriter) Write(p []byte) (n int, err error) {
|
||||
sw.mu.Lock()
|
||||
defer sw.mu.Unlock()
|
||||
return sw.w.Write(p)
|
||||
}
|
||||
|
||||
// newSyncWriter creates a new synchronized writer
|
||||
func newSyncWriter(w io.Writer) io.Writer {
|
||||
return &syncWriter{w: w}
|
||||
}
|
||||
|
||||
// filterTools filters the provided tools based on allowed or excluded tool names
|
||||
func filterTools(allTools []tools.BaseTool, allowedTools, excludedTools []string) []tools.BaseTool {
|
||||
// If neither allowed nor excluded tools are specified, return all tools
|
||||
if len(allowedTools) == 0 && len(excludedTools) == 0 {
|
||||
return allTools
|
||||
}
|
||||
|
||||
// Create a map for faster lookups
|
||||
allowedMap := make(map[string]bool)
|
||||
for _, name := range allowedTools {
|
||||
allowedMap[name] = true
|
||||
}
|
||||
|
||||
excludedMap := make(map[string]bool)
|
||||
for _, name := range excludedTools {
|
||||
excludedMap[name] = true
|
||||
}
|
||||
|
||||
var filteredTools []tools.BaseTool
|
||||
|
||||
for _, tool := range allTools {
|
||||
toolName := tool.Info().Name
|
||||
|
||||
// If we have an allowed list, only include tools in that list
|
||||
if len(allowedTools) > 0 {
|
||||
if allowedMap[toolName] {
|
||||
filteredTools = append(filteredTools, tool)
|
||||
}
|
||||
} else if len(excludedTools) > 0 {
|
||||
// If we have an excluded list, include all tools except those in the list
|
||||
if !excludedMap[toolName] {
|
||||
filteredTools = append(filteredTools, tool)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filteredTools
|
||||
}
|
||||
|
||||
// handleNonInteractiveMode processes a single prompt in non-interactive mode
|
||||
func handleNonInteractiveMode(ctx context.Context, prompt string, outputFormat format.OutputFormat, quiet bool, verbose bool, allowedTools, excludedTools []string) error {
|
||||
// Initial log message using standard slog
|
||||
slog.Info("Running in non-interactive mode", "prompt", prompt, "format", outputFormat, "quiet", quiet, "verbose", verbose,
|
||||
"allowedTools", allowedTools, "excludedTools", excludedTools)
|
||||
|
||||
// Sanity check for mutually exclusive flags
|
||||
if quiet && verbose {
|
||||
return fmt.Errorf("--quiet and --verbose flags cannot be used together")
|
||||
}
|
||||
|
||||
// Set up logging to stderr if verbose mode is enabled
|
||||
if verbose {
|
||||
// Create a synchronized writer to prevent interleaved output
|
||||
syncWriter := newSyncWriter(os.Stderr)
|
||||
|
||||
// Create a charmbracelet/log logger that writes to the synchronized writer
|
||||
charmLogger := charmlog.NewWithOptions(syncWriter, charmlog.Options{
|
||||
Level: charmlog.DebugLevel,
|
||||
ReportCaller: true,
|
||||
ReportTimestamp: true,
|
||||
TimeFormat: time.RFC3339,
|
||||
Prefix: "OpenCode",
|
||||
})
|
||||
|
||||
// Set the global logger for charmbracelet/log
|
||||
charmlog.SetDefault(charmLogger)
|
||||
|
||||
// Create a slog handler that uses charmbracelet/log
|
||||
// This will forward all slog logs to charmbracelet/log
|
||||
slog.SetDefault(slog.New(charmLogger))
|
||||
|
||||
// Log a message to confirm verbose logging is enabled
|
||||
charmLogger.Info("Verbose logging enabled")
|
||||
}
|
||||
|
||||
// Start spinner if not in quiet mode
|
||||
var s *spinner.Spinner
|
||||
if !quiet {
|
||||
// Get the current theme to style the spinner
|
||||
currentTheme := theme.CurrentTheme()
|
||||
|
||||
// Create a themed spinner
|
||||
if currentTheme != nil {
|
||||
// Use the primary color from the theme
|
||||
s = spinner.NewThemedSpinner("Thinking...", currentTheme.Primary())
|
||||
} else {
|
||||
// Fallback to default spinner if no theme is available
|
||||
s = spinner.NewSpinner("Thinking...")
|
||||
}
|
||||
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
}
|
||||
|
||||
// Connect DB, this will also run migrations
|
||||
conn, err := db.Connect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create a context with cancellation
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// Create the app
|
||||
app, err := app.New(ctx, conn)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create app", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Create a new session for this prompt
|
||||
session, err := app.Sessions.Create(ctx, "Non-interactive prompt")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
|
||||
// Set the session as current
|
||||
app.CurrentSession = &session
|
||||
|
||||
// Auto-approve all permissions for this session
|
||||
permission.AutoApproveSession(ctx, session.ID)
|
||||
|
||||
// Create the user message
|
||||
_, err = app.Messages.Create(ctx, session.ID, message.CreateMessageParams{
|
||||
Role: message.User,
|
||||
Parts: []message.ContentPart{message.TextContent{Text: prompt}},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create message: %w", err)
|
||||
}
|
||||
|
||||
// If tool restrictions are specified, create a new agent with filtered tools
|
||||
if len(allowedTools) > 0 || len(excludedTools) > 0 {
|
||||
// Initialize MCP tools synchronously to ensure they're included in filtering
|
||||
mcpCtx, mcpCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
agent.GetMcpTools(mcpCtx, app.Permissions)
|
||||
mcpCancel()
|
||||
|
||||
// Get all available tools including MCP tools
|
||||
allTools := agent.PrimaryAgentTools(
|
||||
app.Permissions,
|
||||
app.Sessions,
|
||||
app.Messages,
|
||||
app.History,
|
||||
app.LSPClients,
|
||||
)
|
||||
|
||||
// Filter tools based on allowed/excluded lists
|
||||
filteredTools := filterTools(allTools, allowedTools, excludedTools)
|
||||
|
||||
// Log the filtered tools for debugging
|
||||
var toolNames []string
|
||||
for _, tool := range filteredTools {
|
||||
toolNames = append(toolNames, tool.Info().Name)
|
||||
}
|
||||
slog.Debug("Using filtered tools", "count", len(filteredTools), "tools", toolNames)
|
||||
|
||||
// Create a new agent with the filtered tools
|
||||
restrictedAgent, err := agent.NewAgent(
|
||||
config.AgentPrimary,
|
||||
app.Sessions,
|
||||
app.Messages,
|
||||
filteredTools,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create restricted agent: %w", err)
|
||||
}
|
||||
|
||||
// Use the restricted agent for this request
|
||||
eventCh, err := restrictedAgent.Run(ctx, session.ID, prompt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run restricted agent: %w", err)
|
||||
}
|
||||
|
||||
// Wait for the response
|
||||
var response message.Message
|
||||
for event := range eventCh {
|
||||
if event.Err() != nil {
|
||||
return fmt.Errorf("agent error: %w", event.Err())
|
||||
}
|
||||
response = event.Response()
|
||||
}
|
||||
|
||||
// Format and print the output
|
||||
content := ""
|
||||
if textContent := response.Content(); textContent != nil {
|
||||
content = textContent.Text
|
||||
}
|
||||
|
||||
formattedOutput, err := format.FormatOutput(content, outputFormat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to format output: %w", err)
|
||||
}
|
||||
|
||||
// Stop spinner before printing output
|
||||
if !quiet && s != nil {
|
||||
s.Stop()
|
||||
}
|
||||
|
||||
// Print the formatted output to stdout
|
||||
fmt.Println(formattedOutput)
|
||||
|
||||
// Shutdown the app
|
||||
app.Shutdown()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run the default agent if no tool restrictions
|
||||
eventCh, err := app.PrimaryAgent.Run(ctx, session.ID, prompt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run agent: %w", err)
|
||||
}
|
||||
|
||||
// Wait for the response
|
||||
var response message.Message
|
||||
for event := range eventCh {
|
||||
if event.Err() != nil {
|
||||
return fmt.Errorf("agent error: %w", event.Err())
|
||||
}
|
||||
response = event.Response()
|
||||
}
|
||||
|
||||
// Get the text content from the response
|
||||
content := ""
|
||||
if textContent := response.Content(); textContent != nil {
|
||||
content = textContent.Text
|
||||
}
|
||||
|
||||
// Format the output according to the specified format
|
||||
formattedOutput, err := format.FormatOutput(content, outputFormat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to format output: %w", err)
|
||||
}
|
||||
|
||||
// Stop spinner before printing output
|
||||
if !quiet && s != nil {
|
||||
s.Stop()
|
||||
}
|
||||
|
||||
// Print the formatted output to stdout
|
||||
fmt.Println(formattedOutput)
|
||||
|
||||
// Shutdown the app
|
||||
app.Shutdown()
|
||||
|
||||
return nil
|
||||
}
|
||||
+32
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/db"
|
||||
"github.com/sst/opencode/internal/format"
|
||||
"github.com/sst/opencode/internal/llm/agent"
|
||||
"github.com/sst/opencode/internal/logging"
|
||||
"github.com/sst/opencode/internal/lsp/discovery"
|
||||
@@ -88,6 +89,25 @@ to assist developers in writing, debugging, and understanding code directly from
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if we're in non-interactive mode
|
||||
prompt, _ := cmd.Flags().GetString("prompt")
|
||||
if prompt != "" {
|
||||
outputFormatStr, _ := cmd.Flags().GetString("output-format")
|
||||
outputFormat := format.OutputFormat(outputFormatStr)
|
||||
if !outputFormat.IsValid() {
|
||||
return fmt.Errorf("invalid output format: %s", outputFormatStr)
|
||||
}
|
||||
|
||||
quiet, _ := cmd.Flags().GetBool("quiet")
|
||||
verbose, _ := cmd.Flags().GetBool("verbose")
|
||||
|
||||
// Get tool restriction flags
|
||||
allowedTools, _ := cmd.Flags().GetStringSlice("allowedTools")
|
||||
excludedTools, _ := cmd.Flags().GetStringSlice("excludedTools")
|
||||
|
||||
return handleNonInteractiveMode(cmd.Context(), prompt, outputFormat, quiet, verbose, allowedTools, excludedTools)
|
||||
}
|
||||
|
||||
// Run LSP auto-discovery
|
||||
if err := discovery.IntegrateLSPServers(cwd); err != nil {
|
||||
slog.Warn("Failed to auto-discover LSP servers", "error", err)
|
||||
@@ -296,4 +316,16 @@ func init() {
|
||||
rootCmd.Flags().BoolP("version", "v", false, "Version")
|
||||
rootCmd.Flags().BoolP("debug", "d", false, "Debug")
|
||||
rootCmd.Flags().StringP("cwd", "c", "", "Current working directory")
|
||||
rootCmd.Flags().StringP("prompt", "p", "", "Run a single prompt in non-interactive mode")
|
||||
rootCmd.Flags().StringP("output-format", "f", "text", "Output format for non-interactive mode (text, json)")
|
||||
rootCmd.Flags().BoolP("quiet", "q", false, "Hide spinner in non-interactive mode")
|
||||
rootCmd.Flags().BoolP("verbose", "", false, "Display logs to stderr in non-interactive mode")
|
||||
rootCmd.Flags().StringSlice("allowedTools", nil, "Restrict the agent to only use the specified tools in non-interactive mode (comma-separated list)")
|
||||
rootCmd.Flags().StringSlice("excludedTools", nil, "Prevent the agent from using the specified tools in non-interactive mode (comma-separated list)")
|
||||
|
||||
// Make allowedTools and excludedTools mutually exclusive
|
||||
rootCmd.MarkFlagsMutuallyExclusive("allowedTools", "excludedTools")
|
||||
|
||||
// Make quiet and verbose mutually exclusive
|
||||
rootCmd.MarkFlagsMutuallyExclusive("quiet", "verbose")
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -11,7 +11,7 @@ require (
|
||||
github.com/aymanbagabas/go-udiff v0.2.0
|
||||
github.com/bmatcuk/doublestar/v4 v4.8.1
|
||||
github.com/catppuccin/go v0.3.0
|
||||
github.com/charmbracelet/bubbles v0.20.0
|
||||
github.com/charmbracelet/bubbles v0.21.0
|
||||
github.com/charmbracelet/bubbletea v1.3.4
|
||||
github.com/charmbracelet/glamour v0.9.1
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
@@ -34,6 +34,11 @@ require (
|
||||
github.com/stretchr/testify v1.10.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/log v0.4.2 // indirect
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.116.0 // indirect
|
||||
cloud.google.com/go/auth v0.13.0 // indirect
|
||||
@@ -42,7 +47,7 @@ require (
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect
|
||||
@@ -115,7 +120,7 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/image v0.26.0 // indirect
|
||||
golang.org/x/image v0.26.0
|
||||
golang.org/x/net v0.39.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
|
||||
@@ -70,6 +70,8 @@ github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
|
||||
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
|
||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
|
||||
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
@@ -78,12 +80,15 @@ github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ
|
||||
github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
|
||||
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -73,6 +73,12 @@ type TUIConfig struct {
|
||||
CustomTheme map[string]any `json:"customTheme,omitempty"`
|
||||
}
|
||||
|
||||
// ShellConfig defines the configuration for the shell used by the bash tool.
|
||||
type ShellConfig struct {
|
||||
Path string `json:"path,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
}
|
||||
|
||||
// Config is the main configuration structure for the application.
|
||||
type Config struct {
|
||||
Data Data `json:"data"`
|
||||
@@ -85,6 +91,7 @@ type Config struct {
|
||||
DebugLSP bool `json:"debugLSP,omitempty"`
|
||||
ContextPaths []string `json:"contextPaths,omitempty"`
|
||||
TUI TUIConfig `json:"tui"`
|
||||
Shell ShellConfig `json:"shell,omitempty"`
|
||||
}
|
||||
|
||||
// Application constants
|
||||
@@ -235,6 +242,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 +307,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 +344,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 +579,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 +703,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
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ var (
|
||||
fzfPath string
|
||||
)
|
||||
|
||||
func init() {
|
||||
func Init() {
|
||||
var err error
|
||||
rgPath, err = exec.LookPath("rg")
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package format
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// OutputFormat represents the format for non-interactive mode output
|
||||
type OutputFormat string
|
||||
|
||||
const (
|
||||
// TextFormat is plain text output (default)
|
||||
TextFormat OutputFormat = "text"
|
||||
|
||||
// JSONFormat is output wrapped in a JSON object
|
||||
JSONFormat OutputFormat = "json"
|
||||
)
|
||||
|
||||
// IsValid checks if the output format is valid
|
||||
func (f OutputFormat) IsValid() bool {
|
||||
return f == TextFormat || f == JSONFormat
|
||||
}
|
||||
|
||||
// String returns the string representation of the output format
|
||||
func (f OutputFormat) String() string {
|
||||
return string(f)
|
||||
}
|
||||
|
||||
// FormatOutput formats the given content according to the specified format
|
||||
func FormatOutput(content string, format OutputFormat) (string, error) {
|
||||
switch format {
|
||||
case TextFormat:
|
||||
return content, nil
|
||||
case JSONFormat:
|
||||
jsonData := map[string]string{
|
||||
"response": content,
|
||||
}
|
||||
jsonBytes, err := json.MarshalIndent(jsonData, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal JSON: %w", err)
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported output format: %s", format)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package format
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOutputFormat_IsValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
format OutputFormat
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "text format",
|
||||
format: TextFormat,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "json format",
|
||||
format: JSONFormat,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "invalid format",
|
||||
format: "invalid",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := tt.format.IsValid(); got != tt.want {
|
||||
t.Errorf("OutputFormat.IsValid() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
format OutputFormat
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "text format",
|
||||
content: "test content",
|
||||
format: TextFormat,
|
||||
want: "test content",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "json format",
|
||||
content: "test content",
|
||||
format: JSONFormat,
|
||||
want: "{\n \"response\": \"test content\"\n}",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid format",
|
||||
content: "test content",
|
||||
format: "invalid",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := FormatOutput(tt.content, tt.format)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("FormatOutput() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("FormatOutput() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+43
-21
@@ -21,30 +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),
|
||||
tools.NewCodeActionTool(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(),
|
||||
@@ -54,4 +65,15 @@ func TaskAgentTools(lspClients map[string]*lsp.Client) []tools.BaseTool {
|
||||
tools.NewDocSymbolsTool(lspClients),
|
||||
tools.NewWorkspaceSymbolsTool(lspClients),
|
||||
}
|
||||
|
||||
// Create a map of tools for the batch tool
|
||||
toolsMap := make(map[string]tools.BaseTool)
|
||||
for _, tool := range toolsList {
|
||||
toolsMap[tool.Info().Name] = tool
|
||||
}
|
||||
|
||||
// Add the batch tool with access to all other tools
|
||||
toolsList = append(toolsList, tools.NewBatchTool(toolsMap))
|
||||
|
||||
return toolsList
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ var ProviderPopularity = map[ModelProvider]int{
|
||||
ProviderOpenRouter: 5,
|
||||
ProviderBedrock: 6,
|
||||
ProviderAzure: 7,
|
||||
ProviderVertexAI: 8,
|
||||
}
|
||||
|
||||
var SupportedModels = map[ModelID]Model{
|
||||
@@ -95,4 +96,5 @@ func init() {
|
||||
maps.Copy(SupportedModels, AzureModels)
|
||||
maps.Copy(SupportedModels, OpenRouterModels)
|
||||
maps.Copy(SupportedModels, XAIModels)
|
||||
maps.Copy(SupportedModels, VertexAIGeminiModels)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package models
|
||||
|
||||
const (
|
||||
ProviderVertexAI ModelProvider = "vertexai"
|
||||
|
||||
// Models
|
||||
VertexAIGemini25Flash ModelID = "vertexai.gemini-2.5-flash"
|
||||
VertexAIGemini25 ModelID = "vertexai.gemini-2.5"
|
||||
)
|
||||
|
||||
var VertexAIGeminiModels = map[ModelID]Model{
|
||||
VertexAIGemini25Flash: {
|
||||
ID: VertexAIGemini25Flash,
|
||||
Name: "VertexAI: Gemini 2.5 Flash",
|
||||
Provider: ProviderVertexAI,
|
||||
APIModel: "gemini-2.5-flash-preview-04-17",
|
||||
CostPer1MIn: GeminiModels[Gemini25Flash].CostPer1MIn,
|
||||
CostPer1MInCached: GeminiModels[Gemini25Flash].CostPer1MInCached,
|
||||
CostPer1MOut: GeminiModels[Gemini25Flash].CostPer1MOut,
|
||||
CostPer1MOutCached: GeminiModels[Gemini25Flash].CostPer1MOutCached,
|
||||
ContextWindow: GeminiModels[Gemini25Flash].ContextWindow,
|
||||
DefaultMaxTokens: GeminiModels[Gemini25Flash].DefaultMaxTokens,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
VertexAIGemini25: {
|
||||
ID: VertexAIGemini25,
|
||||
Name: "VertexAI: Gemini 2.5 Pro",
|
||||
Provider: ProviderVertexAI,
|
||||
APIModel: "gemini-2.5-pro-preview-03-25",
|
||||
CostPer1MIn: GeminiModels[Gemini25].CostPer1MIn,
|
||||
CostPer1MInCached: GeminiModels[Gemini25].CostPer1MInCached,
|
||||
CostPer1MOut: GeminiModels[Gemini25].CostPer1MOut,
|
||||
CostPer1MOutCached: GeminiModels[Gemini25].CostPer1MOutCached,
|
||||
ContextWindow: GeminiModels[Gemini25].ContextWindow,
|
||||
DefaultMaxTokens: GeminiModels[Gemini25].DefaultMaxTokens,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
}
|
||||
@@ -224,15 +224,16 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message,
|
||||
if err != nil {
|
||||
slog.Error("Error in Anthropic API call", "error", err)
|
||||
retry, after, retryErr := a.shouldRetry(attempts, err)
|
||||
duration := time.Duration(after) * time.Millisecond
|
||||
if retryErr != nil {
|
||||
return nil, retryErr
|
||||
}
|
||||
if retry {
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), status.WithDuration(duration))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(time.Duration(after) * time.Millisecond):
|
||||
case <-time.After(duration):
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -360,13 +361,14 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
|
||||
}
|
||||
// If there is an error we are going to see if we can retry the call
|
||||
retry, after, retryErr := a.shouldRetry(attempts, err)
|
||||
duration := time.Duration(after) * time.Millisecond
|
||||
if retryErr != nil {
|
||||
eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
|
||||
close(eventChan)
|
||||
return
|
||||
}
|
||||
if retry {
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), status.WithDuration(duration))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// context cancelled
|
||||
@@ -375,7 +377,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
|
||||
}
|
||||
close(eventChan)
|
||||
return
|
||||
case <-time.After(time.Duration(after) * time.Millisecond):
|
||||
case <-time.After(duration):
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,13 +176,16 @@ func (g *geminiClient) send(ctx context.Context, messages []message.Message, too
|
||||
|
||||
history := geminiMessages[:len(geminiMessages)-1] // All but last message
|
||||
lastMsg := geminiMessages[len(geminiMessages)-1]
|
||||
chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, &genai.GenerateContentConfig{
|
||||
config := &genai.GenerateContentConfig{
|
||||
MaxOutputTokens: int32(g.providerOptions.maxTokens),
|
||||
SystemInstruction: &genai.Content{
|
||||
Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}},
|
||||
},
|
||||
Tools: g.convertTools(tools),
|
||||
}, history)
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
config.Tools = g.convertTools(tools)
|
||||
}
|
||||
chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, config, history)
|
||||
|
||||
attempts := 0
|
||||
for {
|
||||
@@ -197,15 +200,16 @@ func (g *geminiClient) send(ctx context.Context, messages []message.Message, too
|
||||
// If there is an error we are going to see if we can retry the call
|
||||
if err != nil {
|
||||
retry, after, retryErr := g.shouldRetry(attempts, err)
|
||||
duration := time.Duration(after) * time.Millisecond
|
||||
if retryErr != nil {
|
||||
return nil, retryErr
|
||||
}
|
||||
if retry {
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), status.WithDuration(duration))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(time.Duration(after) * time.Millisecond):
|
||||
case <-time.After(duration):
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -261,13 +265,16 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
|
||||
|
||||
history := geminiMessages[:len(geminiMessages)-1] // All but last message
|
||||
lastMsg := geminiMessages[len(geminiMessages)-1]
|
||||
chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, &genai.GenerateContentConfig{
|
||||
config := &genai.GenerateContentConfig{
|
||||
MaxOutputTokens: int32(g.providerOptions.maxTokens),
|
||||
SystemInstruction: &genai.Content{
|
||||
Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}},
|
||||
},
|
||||
Tools: g.convertTools(tools),
|
||||
}, history)
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
config.Tools = g.convertTools(tools)
|
||||
}
|
||||
chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, config, history)
|
||||
|
||||
attempts := 0
|
||||
eventChan := make(chan ProviderEvent)
|
||||
@@ -292,12 +299,13 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
|
||||
for resp, err := range chat.SendMessageStream(ctx, lastMsgParts...) {
|
||||
if err != nil {
|
||||
retry, after, retryErr := g.shouldRetry(attempts, err)
|
||||
duration := time.Duration(after) * time.Millisecond
|
||||
if retryErr != nil {
|
||||
eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
|
||||
return
|
||||
}
|
||||
if retry {
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), status.WithDuration(duration))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if ctx.Err() != nil {
|
||||
@@ -305,7 +313,7 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
|
||||
}
|
||||
|
||||
return
|
||||
case <-time.After(time.Duration(after) * time.Millisecond):
|
||||
case <-time.After(duration):
|
||||
break
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -211,15 +211,16 @@ func (o *openaiClient) send(ctx context.Context, messages []message.Message, too
|
||||
// If there is an error we are going to see if we can retry the call
|
||||
if err != nil {
|
||||
retry, after, retryErr := o.shouldRetry(attempts, err)
|
||||
duration := time.Duration(after) * time.Millisecond
|
||||
if retryErr != nil {
|
||||
return nil, retryErr
|
||||
}
|
||||
if retry {
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), status.WithDuration(duration))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(time.Duration(after) * time.Millisecond):
|
||||
case <-time.After(duration):
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -315,13 +316,14 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
|
||||
|
||||
// If there is an error we are going to see if we can retry the call
|
||||
retry, after, retryErr := o.shouldRetry(attempts, err)
|
||||
duration := time.Duration(after) * time.Millisecond
|
||||
if retryErr != nil {
|
||||
eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
|
||||
close(eventChan)
|
||||
return
|
||||
}
|
||||
if retry {
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), status.WithDuration(duration))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// context cancelled
|
||||
@@ -330,7 +332,7 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
|
||||
}
|
||||
close(eventChan)
|
||||
return
|
||||
case <-time.After(time.Duration(after) * time.Millisecond):
|
||||
case <-time.After(duration):
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,11 @@ func NewProvider(providerName models.ModelProvider, opts ...ProviderClientOption
|
||||
options: clientOptions,
|
||||
client: newAzureClient(clientOptions),
|
||||
}, nil
|
||||
case models.ProviderVertexAI:
|
||||
return &baseProvider[VertexAIClient]{
|
||||
options: clientOptions,
|
||||
client: newVertexAIClient(clientOptions),
|
||||
}, nil
|
||||
case models.ProviderOpenRouter:
|
||||
clientOptions.openaiOptions = append(clientOptions.openaiOptions,
|
||||
WithOpenAIBaseURL("https://openrouter.ai/api/v1"),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type BatchToolCall struct {
|
||||
Name string `json:"name"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
}
|
||||
|
||||
type BatchParams struct {
|
||||
Calls []BatchToolCall `json:"calls"`
|
||||
}
|
||||
|
||||
type BatchToolResult struct {
|
||||
ToolName string `json:"tool_name"`
|
||||
ToolInput json.RawMessage `json:"tool_input"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// Added for better formatting and separation between results
|
||||
Separator string `json:"separator,omitempty"`
|
||||
}
|
||||
|
||||
type BatchResult struct {
|
||||
Results []BatchToolResult `json:"results"`
|
||||
}
|
||||
|
||||
type batchTool struct {
|
||||
tools map[string]BaseTool
|
||||
}
|
||||
|
||||
const (
|
||||
BatchToolName = "batch"
|
||||
BatchToolDescription = `Executes multiple tool calls in parallel and returns their results.
|
||||
|
||||
WHEN TO USE THIS TOOL:
|
||||
- Use when you need to run multiple independent tool calls at once
|
||||
- Helpful for improving performance by parallelizing operations
|
||||
- Great for gathering information from multiple sources simultaneously
|
||||
|
||||
HOW TO USE:
|
||||
- Provide an array of tool calls, each with a name and input
|
||||
- Each tool call will be executed in parallel
|
||||
- Results are returned in the same order as the input calls
|
||||
|
||||
FEATURES:
|
||||
- Runs tool calls concurrently for better performance
|
||||
- Returns both results and errors for each call
|
||||
- Maintains the order of results to match input calls
|
||||
|
||||
LIMITATIONS:
|
||||
- All tools must be available in the current context
|
||||
- Complex error handling may be required for some use cases
|
||||
- Not suitable for tool calls that depend on each other's results
|
||||
|
||||
TIPS:
|
||||
- Use for independent operations like multiple file reads or searches
|
||||
- Great for batch operations like searching multiple directories
|
||||
- Combine with other tools for more complex workflows`
|
||||
)
|
||||
|
||||
func NewBatchTool(tools map[string]BaseTool) BaseTool {
|
||||
return &batchTool{
|
||||
tools: tools,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *batchTool) Info() ToolInfo {
|
||||
return ToolInfo{
|
||||
Name: BatchToolName,
|
||||
Description: BatchToolDescription,
|
||||
Parameters: map[string]any{
|
||||
"calls": map[string]any{
|
||||
"type": "array",
|
||||
"description": "Array of tool calls to execute in parallel",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"name": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Name of the tool to call",
|
||||
},
|
||||
"input": map[string]any{
|
||||
"type": "object",
|
||||
"description": "Input parameters for the tool",
|
||||
},
|
||||
},
|
||||
"required": []string{"name", "input"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"calls"},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *batchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
|
||||
var params BatchParams
|
||||
if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
|
||||
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
|
||||
}
|
||||
|
||||
if len(params.Calls) == 0 {
|
||||
return NewTextErrorResponse("no tool calls provided"), nil
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([]BatchToolResult, len(params.Calls))
|
||||
|
||||
for i, toolCall := range params.Calls {
|
||||
wg.Add(1)
|
||||
go func(index int, tc BatchToolCall) {
|
||||
defer wg.Done()
|
||||
|
||||
// Create separator for better visual distinction between results
|
||||
separator := ""
|
||||
if index > 0 {
|
||||
separator = fmt.Sprintf("\n%s\n", strings.Repeat("=", 80))
|
||||
}
|
||||
|
||||
result := BatchToolResult{
|
||||
ToolName: tc.Name,
|
||||
ToolInput: tc.Input,
|
||||
Separator: separator,
|
||||
}
|
||||
|
||||
tool, ok := b.tools[tc.Name]
|
||||
if !ok {
|
||||
result.Error = fmt.Sprintf("tool not found: %s", tc.Name)
|
||||
results[index] = result
|
||||
return
|
||||
}
|
||||
|
||||
// Create a proper ToolCall object
|
||||
callObj := ToolCall{
|
||||
ID: fmt.Sprintf("batch-%d", index),
|
||||
Name: tc.Name,
|
||||
Input: string(tc.Input),
|
||||
}
|
||||
|
||||
response, err := tool.Run(ctx, callObj)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("error executing tool %s: %s", tc.Name, err)
|
||||
results[index] = result
|
||||
return
|
||||
}
|
||||
|
||||
// Standardize metadata format if present
|
||||
if response.Metadata != "" {
|
||||
var metadata map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(response.Metadata), &metadata); err == nil {
|
||||
// Add tool name to metadata for better context
|
||||
metadata["tool"] = tc.Name
|
||||
|
||||
// Re-marshal with consistent formatting
|
||||
if metadataBytes, err := json.MarshalIndent(metadata, "", " "); err == nil {
|
||||
response.Metadata = string(metadataBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the response to JSON
|
||||
responseJSON, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("error marshaling response: %s", err)
|
||||
results[index] = result
|
||||
return
|
||||
}
|
||||
|
||||
result.Result = responseJSON
|
||||
results[index] = result
|
||||
}(i, toolCall)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
batchResult := BatchResult{
|
||||
Results: results,
|
||||
}
|
||||
|
||||
resultJSON, err := json.Marshal(batchResult)
|
||||
if err != nil {
|
||||
return NewTextErrorResponse(fmt.Sprintf("error marshaling batch result: %s", err)), nil
|
||||
}
|
||||
|
||||
return NewTextResponse(string(resultJSON)), nil
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -196,16 +196,11 @@ func (e *editTool) createNewFile(ctx context.Context, filePath, content string)
|
||||
content,
|
||||
filePath,
|
||||
)
|
||||
rootDir := config.WorkingDirectory()
|
||||
permissionPath := filepath.Dir(filePath)
|
||||
if strings.HasPrefix(filePath, rootDir) {
|
||||
permissionPath = rootDir
|
||||
}
|
||||
p := e.permissions.Request(
|
||||
ctx,
|
||||
permission.CreatePermissionRequest{
|
||||
SessionID: sessionID,
|
||||
Path: permissionPath,
|
||||
Path: filePath,
|
||||
ToolName: EditToolName,
|
||||
Action: "write",
|
||||
Description: fmt.Sprintf("Create file %s", filePath),
|
||||
@@ -308,16 +303,11 @@ func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string
|
||||
filePath,
|
||||
)
|
||||
|
||||
rootDir := config.WorkingDirectory()
|
||||
permissionPath := filepath.Dir(filePath)
|
||||
if strings.HasPrefix(filePath, rootDir) {
|
||||
permissionPath = rootDir
|
||||
}
|
||||
p := e.permissions.Request(
|
||||
ctx,
|
||||
permission.CreatePermissionRequest{
|
||||
SessionID: sessionID,
|
||||
Path: permissionPath,
|
||||
Path: filePath,
|
||||
ToolName: EditToolName,
|
||||
Action: "write",
|
||||
Description: fmt.Sprintf("Delete content from file %s", filePath),
|
||||
@@ -429,16 +419,11 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS
|
||||
newContent,
|
||||
filePath,
|
||||
)
|
||||
rootDir := config.WorkingDirectory()
|
||||
permissionPath := filepath.Dir(filePath)
|
||||
if strings.HasPrefix(filePath, rootDir) {
|
||||
permissionPath = rootDir
|
||||
}
|
||||
p := e.permissions.Request(
|
||||
ctx,
|
||||
permission.CreatePermissionRequest{
|
||||
SessionID: sessionID,
|
||||
Path: permissionPath,
|
||||
Path: filePath,
|
||||
ToolName: EditToolName,
|
||||
Action: "write",
|
||||
Description: fmt.Sprintf("Replace content in file %s", filePath),
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
)
|
||||
|
||||
@@ -59,12 +60,27 @@ func GetPersistentShell(workingDir string) *PersistentShell {
|
||||
}
|
||||
|
||||
func newPersistentShell(cwd string) *PersistentShell {
|
||||
shellPath := os.Getenv("SHELL")
|
||||
if shellPath == "" {
|
||||
shellPath = "/bin/bash"
|
||||
cfg := config.Get()
|
||||
|
||||
// Use shell from config if specified
|
||||
shellPath := ""
|
||||
shellArgs := []string{"-l"}
|
||||
|
||||
if cfg != nil && cfg.Shell.Path != "" {
|
||||
shellPath = cfg.Shell.Path
|
||||
if len(cfg.Shell.Args) > 0 {
|
||||
shellArgs = cfg.Shell.Args
|
||||
}
|
||||
} else {
|
||||
// Fall back to environment variable
|
||||
shellPath = os.Getenv("SHELL")
|
||||
if shellPath == "" {
|
||||
// Default to bash if neither config nor environment variable is set
|
||||
shellPath = "/bin/bash"
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command(shellPath, "-l")
|
||||
cmd := exec.Command(shellPath, shellArgs...)
|
||||
cmd.Dir = cwd
|
||||
|
||||
stdinPipe, err := cmd.StdinPipe()
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sst/opencode/internal/config"
|
||||
@@ -161,16 +160,11 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
|
||||
filePath,
|
||||
)
|
||||
|
||||
rootDir := config.WorkingDirectory()
|
||||
permissionPath := filepath.Dir(filePath)
|
||||
if strings.HasPrefix(filePath, rootDir) {
|
||||
permissionPath = rootDir
|
||||
}
|
||||
p := w.permissions.Request(
|
||||
ctx,
|
||||
permission.CreatePermissionRequest{
|
||||
SessionID: sessionID,
|
||||
Path: permissionPath,
|
||||
Path: filePath,
|
||||
ToolName: WriteToolName,
|
||||
Action: "write",
|
||||
Description: fmt.Sprintf("Create file %s", filePath),
|
||||
|
||||
+49
-24
@@ -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] {
|
||||
|
||||
@@ -2,6 +2,7 @@ package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"github.com/sst/opencode/internal/message"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
"github.com/sst/opencode/internal/tui/components/dialog"
|
||||
"github.com/sst/opencode/internal/tui/image"
|
||||
"github.com/sst/opencode/internal/tui/layout"
|
||||
"github.com/sst/opencode/internal/tui/styles"
|
||||
"github.com/sst/opencode/internal/tui/theme"
|
||||
@@ -34,6 +36,7 @@ type editorCmp struct {
|
||||
type EditorKeyMaps struct {
|
||||
Send key.Binding
|
||||
OpenEditor key.Binding
|
||||
Paste key.Binding
|
||||
}
|
||||
|
||||
type bluredEditorKeyMaps struct {
|
||||
@@ -56,6 +59,10 @@ var editorMaps = EditorKeyMaps{
|
||||
key.WithKeys("ctrl+e"),
|
||||
key.WithHelp("ctrl+e", "open editor"),
|
||||
),
|
||||
Paste: key.NewBinding(
|
||||
key.WithKeys("ctrl+v"),
|
||||
key.WithHelp("ctrl+v", "paste content"),
|
||||
),
|
||||
}
|
||||
|
||||
var DeleteKeyMaps = DeleteAttachmentKeyMaps{
|
||||
@@ -200,6 +207,22 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.deleteMode = false
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if key.Matches(msg, editorMaps.Paste) {
|
||||
imageBytes, text, err := image.GetImageFromClipboard()
|
||||
if err != nil {
|
||||
slog.Error(err.Error())
|
||||
return m, cmd
|
||||
}
|
||||
if len(imageBytes) != 0 {
|
||||
attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
|
||||
attachment := message.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"}
|
||||
m.attachments = append(m.attachments, attachment)
|
||||
} else {
|
||||
m.textarea.SetValue(m.textarea.Value() + text)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
// Handle Enter key
|
||||
if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
|
||||
value := m.textarea.Value()
|
||||
@@ -243,7 +266,6 @@ func (m *editorCmp) SetSize(width, height int) tea.Cmd {
|
||||
m.height = height
|
||||
m.textarea.SetWidth(width - 3) // account for the prompt and padding right
|
||||
m.textarea.SetHeight(height)
|
||||
m.textarea.SetWidth(width)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -266,6 +266,8 @@ func toolName(name string) string {
|
||||
return "Write"
|
||||
case tools.PatchToolName:
|
||||
return "Patch"
|
||||
case tools.BatchToolName:
|
||||
return "Batch"
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -292,6 +294,8 @@ func getToolAction(name string) string {
|
||||
return "Preparing write..."
|
||||
case tools.PatchToolName:
|
||||
return "Preparing patch..."
|
||||
case tools.BatchToolName:
|
||||
return "Running batch operations..."
|
||||
}
|
||||
return "Working..."
|
||||
}
|
||||
@@ -443,6 +447,10 @@ func renderToolParams(paramWidth int, toolCall message.ToolCall) string {
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
filePath := removeWorkingDirPrefix(params.FilePath)
|
||||
return renderParams(paramWidth, filePath)
|
||||
case tools.BatchToolName:
|
||||
var params tools.BatchParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
return renderParams(paramWidth, fmt.Sprintf("%d parallel calls", len(params.Calls)))
|
||||
default:
|
||||
input := strings.ReplaceAll(toolCall.Input, "\n", " ")
|
||||
params = renderParams(paramWidth, input)
|
||||
@@ -540,6 +548,38 @@ func renderToolResponse(toolCall message.ToolCall, response message.ToolResult,
|
||||
toMarkdown(resultContent, true, width),
|
||||
t.Background(),
|
||||
)
|
||||
case tools.BatchToolName:
|
||||
var batchResult tools.BatchResult
|
||||
if err := json.Unmarshal([]byte(resultContent), &batchResult); err != nil {
|
||||
return baseStyle.Width(width).Foreground(t.Error()).Render(fmt.Sprintf("Error parsing batch result: %s", err))
|
||||
}
|
||||
|
||||
var toolCalls []string
|
||||
for i, result := range batchResult.Results {
|
||||
toolName := toolName(result.ToolName)
|
||||
|
||||
// Format the tool input as a string
|
||||
inputStr := string(result.ToolInput)
|
||||
|
||||
// Format the result
|
||||
var resultStr string
|
||||
if result.Error != "" {
|
||||
resultStr = fmt.Sprintf("Error: %s", result.Error)
|
||||
} else {
|
||||
var toolResponse tools.ToolResponse
|
||||
if err := json.Unmarshal(result.Result, &toolResponse); err != nil {
|
||||
resultStr = "Error parsing tool response"
|
||||
} else {
|
||||
resultStr = truncateHeight(toolResponse.Content, 3)
|
||||
}
|
||||
}
|
||||
|
||||
// Format the tool call
|
||||
toolCall := fmt.Sprintf("%d. %s: %s\n %s", i+1, toolName, inputStr, resultStr)
|
||||
toolCalls = append(toolCalls, toolCall)
|
||||
}
|
||||
|
||||
return baseStyle.Width(width).Foreground(t.TextMuted()).Render(strings.Join(toolCalls, "\n\n"))
|
||||
default:
|
||||
resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
|
||||
return styles.ForceReplaceBackgroundWithLipgloss(
|
||||
|
||||
@@ -71,8 +71,7 @@ func (m *sidebarCmp) View() string {
|
||||
return baseStyle.
|
||||
Width(m.width).
|
||||
PaddingLeft(4).
|
||||
PaddingRight(2).
|
||||
Height(m.height - 1).
|
||||
PaddingRight(1).
|
||||
Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Top,
|
||||
@@ -98,14 +97,9 @@ func (m *sidebarCmp) sessionSection() string {
|
||||
|
||||
sessionValue := baseStyle.
|
||||
Foreground(t.Text()).
|
||||
Width(m.width - lipgloss.Width(sessionKey)).
|
||||
Render(fmt.Sprintf(": %s", m.app.CurrentSession.Title))
|
||||
|
||||
return lipgloss.JoinHorizontal(
|
||||
lipgloss.Left,
|
||||
sessionKey,
|
||||
sessionValue,
|
||||
)
|
||||
return sessionKey + sessionValue
|
||||
}
|
||||
|
||||
func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
|
||||
|
||||
@@ -24,17 +24,11 @@ type StatusCmp interface {
|
||||
}
|
||||
|
||||
type statusCmp struct {
|
||||
app *app.App
|
||||
statusMessages []statusMessage
|
||||
width int
|
||||
messageTTL time.Duration
|
||||
}
|
||||
|
||||
type statusMessage struct {
|
||||
Level status.Level
|
||||
Message string
|
||||
Timestamp time.Time
|
||||
ExpiresAt time.Time
|
||||
app *app.App
|
||||
queue []status.StatusMessage
|
||||
width int
|
||||
messageTTL time.Duration
|
||||
activeUntil time.Time
|
||||
}
|
||||
|
||||
// clearMessageCmd is a command that clears status messages after a timeout
|
||||
@@ -60,23 +54,50 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
case pubsub.Event[status.StatusMessage]:
|
||||
if msg.Type == status.EventStatusPublished {
|
||||
statusMsg := statusMessage{
|
||||
Level: msg.Payload.Level,
|
||||
Message: msg.Payload.Message,
|
||||
Timestamp: msg.Payload.Timestamp,
|
||||
ExpiresAt: msg.Payload.Timestamp.Add(m.messageTTL),
|
||||
// If this is a critical message, move it to the front of the queue
|
||||
if msg.Payload.Critical {
|
||||
// Insert at the front of the queue
|
||||
m.queue = append([]status.StatusMessage{msg.Payload}, m.queue...)
|
||||
|
||||
// Reset active time to show critical message immediately
|
||||
m.activeUntil = time.Time{}
|
||||
} else {
|
||||
// Otherwise, just add it to the queue
|
||||
m.queue = append(m.queue, msg.Payload)
|
||||
|
||||
// If this is the first message and nothing is active, activate it immediately
|
||||
if len(m.queue) == 1 && m.activeUntil.IsZero() {
|
||||
now := time.Now()
|
||||
duration := m.messageTTL
|
||||
if msg.Payload.Duration > 0 {
|
||||
duration = msg.Payload.Duration
|
||||
}
|
||||
m.activeUntil = now.Add(duration)
|
||||
}
|
||||
}
|
||||
m.statusMessages = append(m.statusMessages, statusMsg)
|
||||
}
|
||||
case statusCleanupMsg:
|
||||
// Remove expired messages
|
||||
var activeMessages []statusMessage
|
||||
for _, sm := range m.statusMessages {
|
||||
if sm.ExpiresAt.After(msg.time) {
|
||||
activeMessages = append(activeMessages, sm)
|
||||
now := msg.time
|
||||
|
||||
// If the active message has expired, remove it and activate the next one
|
||||
if !m.activeUntil.IsZero() && m.activeUntil.Before(now) {
|
||||
// Current message expired, remove it if we have one
|
||||
if len(m.queue) > 0 {
|
||||
m.queue = m.queue[1:]
|
||||
}
|
||||
m.activeUntil = time.Time{}
|
||||
}
|
||||
m.statusMessages = activeMessages
|
||||
|
||||
// If we have messages in queue but none are active, activate the first one
|
||||
if len(m.queue) > 0 && m.activeUntil.IsZero() {
|
||||
// Use custom duration if specified, otherwise use default
|
||||
duration := m.messageTTL
|
||||
if m.queue[0].Duration > 0 {
|
||||
duration = m.queue[0].Duration
|
||||
}
|
||||
m.activeUntil = now.Add(duration)
|
||||
}
|
||||
|
||||
return m, m.clearMessageCmd()
|
||||
}
|
||||
return m, nil
|
||||
@@ -155,12 +176,14 @@ func (m statusCmp) View() string {
|
||||
lipgloss.Width(diagnostics),
|
||||
)
|
||||
|
||||
const minInlineWidth = 30
|
||||
|
||||
// Display the first status message if available
|
||||
if len(m.statusMessages) > 0 {
|
||||
sm := m.statusMessages[0]
|
||||
var statusMessage string
|
||||
if len(m.queue) > 0 {
|
||||
sm := m.queue[0]
|
||||
infoStyle := styles.Padded().
|
||||
Foreground(t.Background()).
|
||||
Width(statusWidth)
|
||||
Foreground(t.Background())
|
||||
|
||||
switch sm.Level {
|
||||
case "info":
|
||||
@@ -176,11 +199,27 @@ func (m statusCmp) View() string {
|
||||
// Truncate message if it's longer than available width
|
||||
msg := sm.Message
|
||||
availWidth := statusWidth - 10
|
||||
if len(msg) > availWidth && availWidth > 0 {
|
||||
msg = msg[:availWidth] + "..."
|
||||
}
|
||||
|
||||
status += infoStyle.Render(msg)
|
||||
// If we have enough space, show inline
|
||||
if availWidth >= minInlineWidth {
|
||||
if len(msg) > availWidth && availWidth > 0 {
|
||||
msg = msg[:availWidth] + "..."
|
||||
}
|
||||
status += infoStyle.Width(statusWidth).Render(msg)
|
||||
} else {
|
||||
// Otherwise, prepare a full-width message to show above
|
||||
if len(msg) > m.width-10 && m.width > 10 {
|
||||
msg = msg[:m.width-10] + "..."
|
||||
}
|
||||
statusMessage = infoStyle.Width(m.width).Render(msg)
|
||||
|
||||
// Add empty space in the status bar
|
||||
status += styles.Padded().
|
||||
Foreground(t.Text()).
|
||||
Background(t.BackgroundSecondary()).
|
||||
Width(statusWidth).
|
||||
Render("")
|
||||
}
|
||||
} else {
|
||||
status += styles.Padded().
|
||||
Foreground(t.Text()).
|
||||
@@ -191,7 +230,14 @@ func (m statusCmp) View() string {
|
||||
|
||||
status += diagnostics
|
||||
status += modelName
|
||||
return status
|
||||
|
||||
// If we have a separate status message, prepend it
|
||||
if statusMessage != "" {
|
||||
return statusMessage + "\n" + status
|
||||
} else {
|
||||
blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
|
||||
return blank + "\n" + status
|
||||
}
|
||||
}
|
||||
|
||||
func (m *statusCmp) projectDiagnostics() string {
|
||||
@@ -234,6 +280,16 @@ func (m *statusCmp) projectDiagnostics() string {
|
||||
}
|
||||
}
|
||||
|
||||
if len(errorDiagnostics) == 0 &&
|
||||
len(warnDiagnostics) == 0 &&
|
||||
len(infoDiagnostics) == 0 &&
|
||||
len(hintDiagnostics) == 0 {
|
||||
return styles.ForceReplaceBackgroundWithLipgloss(
|
||||
styles.Padded().Render("No diagnostics"),
|
||||
t.BackgroundDarker(),
|
||||
)
|
||||
}
|
||||
|
||||
diagnostics := []string{}
|
||||
|
||||
errStr := lipgloss.NewStyle().
|
||||
@@ -293,9 +349,10 @@ func NewStatusCmp(app *app.App) StatusCmp {
|
||||
helpWidget = getHelpWidget("")
|
||||
|
||||
statusComponent := &statusCmp{
|
||||
app: app,
|
||||
statusMessages: []statusMessage{},
|
||||
messageTTL: 4 * time.Second,
|
||||
app: app,
|
||||
queue: []status.StatusMessage{},
|
||||
messageTTL: 4 * time.Second,
|
||||
activeUntil: time.Time{},
|
||||
}
|
||||
|
||||
return statusComponent
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@@ -11,35 +12,6 @@ import (
|
||||
"github.com/sst/opencode/internal/tui/util"
|
||||
)
|
||||
|
||||
// ArgumentsDialogCmp is a component that asks the user for command arguments.
|
||||
type ArgumentsDialogCmp struct {
|
||||
width, height int
|
||||
textInput textinput.Model
|
||||
keys argumentsDialogKeyMap
|
||||
commandID string
|
||||
content string
|
||||
}
|
||||
|
||||
// NewArgumentsDialogCmp creates a new ArgumentsDialogCmp.
|
||||
func NewArgumentsDialogCmp(commandID, content string) ArgumentsDialogCmp {
|
||||
t := theme.CurrentTheme()
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "Enter arguments..."
|
||||
ti.Focus()
|
||||
ti.Width = 40
|
||||
ti.Prompt = ""
|
||||
ti.PlaceholderStyle = ti.PlaceholderStyle.Background(t.Background())
|
||||
ti.PromptStyle = ti.PromptStyle.Background(t.Background())
|
||||
ti.TextStyle = ti.TextStyle.Background(t.Background())
|
||||
|
||||
return ArgumentsDialogCmp{
|
||||
textInput: ti,
|
||||
keys: argumentsDialogKeyMap{},
|
||||
commandID: commandID,
|
||||
content: content,
|
||||
}
|
||||
}
|
||||
|
||||
type argumentsDialogKeyMap struct {
|
||||
Enter key.Binding
|
||||
Escape key.Binding
|
||||
@@ -64,77 +36,204 @@ func (k argumentsDialogKeyMap) FullHelp() [][]key.Binding {
|
||||
return [][]key.Binding{k.ShortHelp()}
|
||||
}
|
||||
|
||||
// ShowMultiArgumentsDialogMsg is a message that is sent to show the multi-arguments dialog.
|
||||
type ShowMultiArgumentsDialogMsg struct {
|
||||
CommandID string
|
||||
Content string
|
||||
ArgNames []string
|
||||
}
|
||||
|
||||
// CloseMultiArgumentsDialogMsg is a message that is sent when the multi-arguments dialog is closed.
|
||||
type CloseMultiArgumentsDialogMsg struct {
|
||||
Submit bool
|
||||
CommandID string
|
||||
Content string
|
||||
Args map[string]string
|
||||
}
|
||||
|
||||
// MultiArgumentsDialogCmp is a component that asks the user for multiple command arguments.
|
||||
type MultiArgumentsDialogCmp struct {
|
||||
width, height int
|
||||
inputs []textinput.Model
|
||||
focusIndex int
|
||||
keys argumentsDialogKeyMap
|
||||
commandID string
|
||||
content string
|
||||
argNames []string
|
||||
}
|
||||
|
||||
// NewMultiArgumentsDialogCmp creates a new MultiArgumentsDialogCmp.
|
||||
func NewMultiArgumentsDialogCmp(commandID, content string, argNames []string) MultiArgumentsDialogCmp {
|
||||
t := theme.CurrentTheme()
|
||||
inputs := make([]textinput.Model, len(argNames))
|
||||
|
||||
for i, name := range argNames {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = fmt.Sprintf("Enter value for %s...", name)
|
||||
ti.Width = 40
|
||||
ti.Prompt = ""
|
||||
ti.PlaceholderStyle = ti.PlaceholderStyle.Background(t.Background())
|
||||
ti.PromptStyle = ti.PromptStyle.Background(t.Background())
|
||||
ti.TextStyle = ti.TextStyle.Background(t.Background())
|
||||
|
||||
// Only focus the first input initially
|
||||
if i == 0 {
|
||||
ti.Focus()
|
||||
ti.PromptStyle = ti.PromptStyle.Foreground(t.Primary())
|
||||
ti.TextStyle = ti.TextStyle.Foreground(t.Primary())
|
||||
} else {
|
||||
ti.Blur()
|
||||
}
|
||||
|
||||
inputs[i] = ti
|
||||
}
|
||||
|
||||
return MultiArgumentsDialogCmp{
|
||||
inputs: inputs,
|
||||
keys: argumentsDialogKeyMap{},
|
||||
commandID: commandID,
|
||||
content: content,
|
||||
argNames: argNames,
|
||||
focusIndex: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Init implements tea.Model.
|
||||
func (m ArgumentsDialogCmp) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
textinput.Blink,
|
||||
m.textInput.Focus(),
|
||||
)
|
||||
func (m MultiArgumentsDialogCmp) Init() tea.Cmd {
|
||||
// Make sure only the first input is focused
|
||||
for i := range m.inputs {
|
||||
if i == 0 {
|
||||
m.inputs[i].Focus()
|
||||
} else {
|
||||
m.inputs[i].Blur()
|
||||
}
|
||||
}
|
||||
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
// Update implements tea.Model.
|
||||
func (m ArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
func (m MultiArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
|
||||
return m, util.CmdHandler(CloseArgumentsDialogMsg{})
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
|
||||
return m, util.CmdHandler(CloseArgumentsDialogMsg{
|
||||
Submit: true,
|
||||
return m, util.CmdHandler(CloseMultiArgumentsDialogMsg{
|
||||
Submit: false,
|
||||
CommandID: m.commandID,
|
||||
Content: m.content,
|
||||
Arguments: m.textInput.Value(),
|
||||
Args: nil,
|
||||
})
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
|
||||
// If we're on the last input, submit the form
|
||||
if m.focusIndex == len(m.inputs)-1 {
|
||||
args := make(map[string]string)
|
||||
for i, name := range m.argNames {
|
||||
args[name] = m.inputs[i].Value()
|
||||
}
|
||||
return m, util.CmdHandler(CloseMultiArgumentsDialogMsg{
|
||||
Submit: true,
|
||||
CommandID: m.commandID,
|
||||
Content: m.content,
|
||||
Args: args,
|
||||
})
|
||||
}
|
||||
// Otherwise, move to the next input
|
||||
m.inputs[m.focusIndex].Blur()
|
||||
m.focusIndex++
|
||||
m.inputs[m.focusIndex].Focus()
|
||||
m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
|
||||
m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
|
||||
// Move to the next input
|
||||
m.inputs[m.focusIndex].Blur()
|
||||
m.focusIndex = (m.focusIndex + 1) % len(m.inputs)
|
||||
m.inputs[m.focusIndex].Focus()
|
||||
m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
|
||||
m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("shift+tab"))):
|
||||
// Move to the previous input
|
||||
m.inputs[m.focusIndex].Blur()
|
||||
m.focusIndex = (m.focusIndex - 1 + len(m.inputs)) % len(m.inputs)
|
||||
m.inputs[m.focusIndex].Focus()
|
||||
m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
|
||||
m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
}
|
||||
|
||||
m.textInput, cmd = m.textInput.Update(msg)
|
||||
// Update the focused input
|
||||
var cmd tea.Cmd
|
||||
m.inputs[m.focusIndex], cmd = m.inputs[m.focusIndex].Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// View implements tea.Model.
|
||||
func (m ArgumentsDialogCmp) View() string {
|
||||
func (m MultiArgumentsDialogCmp) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.BaseStyle()
|
||||
|
||||
// Calculate width needed for content
|
||||
maxWidth := 60 // Width for explanation text
|
||||
|
||||
title := baseStyle.
|
||||
title := lipgloss.NewStyle().
|
||||
Foreground(t.Primary()).
|
||||
Bold(true).
|
||||
Width(maxWidth).
|
||||
Padding(0, 1).
|
||||
Background(t.Background()).
|
||||
Render("Command Arguments")
|
||||
|
||||
explanation := baseStyle.
|
||||
explanation := lipgloss.NewStyle().
|
||||
Foreground(t.Text()).
|
||||
Width(maxWidth).
|
||||
Padding(0, 1).
|
||||
Render("This command requires arguments. Please enter the text to replace $ARGUMENTS with:")
|
||||
Background(t.Background()).
|
||||
Render("This command requires multiple arguments. Please enter values for each:")
|
||||
|
||||
inputField := baseStyle.
|
||||
Foreground(t.Text()).
|
||||
Width(maxWidth).
|
||||
Padding(1, 1).
|
||||
Render(m.textInput.View())
|
||||
// Create input fields for each argument
|
||||
inputFields := make([]string, len(m.inputs))
|
||||
for i, input := range m.inputs {
|
||||
// Highlight the label of the focused input
|
||||
labelStyle := lipgloss.NewStyle().
|
||||
Width(maxWidth).
|
||||
Padding(1, 1, 0, 1).
|
||||
Background(t.Background())
|
||||
|
||||
if i == m.focusIndex {
|
||||
labelStyle = labelStyle.Foreground(t.Primary()).Bold(true)
|
||||
} else {
|
||||
labelStyle = labelStyle.Foreground(t.TextMuted())
|
||||
}
|
||||
|
||||
label := labelStyle.Render(m.argNames[i] + ":")
|
||||
|
||||
field := lipgloss.NewStyle().
|
||||
Foreground(t.Text()).
|
||||
Width(maxWidth).
|
||||
Padding(0, 1).
|
||||
Background(t.Background()).
|
||||
Render(input.View())
|
||||
|
||||
inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field)
|
||||
}
|
||||
|
||||
maxWidth = min(maxWidth, m.width-10)
|
||||
|
||||
// Join all elements vertically
|
||||
elements := []string{title, explanation}
|
||||
elements = append(elements, inputFields...)
|
||||
|
||||
content := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
title,
|
||||
explanation,
|
||||
inputField,
|
||||
elements...,
|
||||
)
|
||||
|
||||
return baseStyle.Padding(1, 2).
|
||||
@@ -147,26 +246,12 @@ func (m ArgumentsDialogCmp) View() string {
|
||||
}
|
||||
|
||||
// SetSize sets the size of the component.
|
||||
func (m *ArgumentsDialogCmp) SetSize(width, height int) {
|
||||
func (m *MultiArgumentsDialogCmp) SetSize(width, height int) {
|
||||
m.width = width
|
||||
m.height = height
|
||||
}
|
||||
|
||||
// Bindings implements layout.Bindings.
|
||||
func (m ArgumentsDialogCmp) Bindings() []key.Binding {
|
||||
func (m MultiArgumentsDialogCmp) Bindings() []key.Binding {
|
||||
return m.keys.ShortHelp()
|
||||
}
|
||||
|
||||
// CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed.
|
||||
type CloseArgumentsDialogMsg struct {
|
||||
Submit bool
|
||||
CommandID string
|
||||
Content string
|
||||
Arguments string
|
||||
}
|
||||
|
||||
// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog.
|
||||
type ShowArgumentsDialogMsg struct {
|
||||
CommandID string
|
||||
Content string
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
func TestNamedArgPattern(t *testing.T) {
|
||||
testCases := []struct {
|
||||
input string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
input: "This is a test with $ARGUMENTS placeholder",
|
||||
expected: []string{"ARGUMENTS"},
|
||||
},
|
||||
{
|
||||
input: "This is a test with $FOO and $BAR placeholders",
|
||||
expected: []string{"FOO", "BAR"},
|
||||
},
|
||||
{
|
||||
input: "This is a test with $FOO_BAR and $BAZ123 placeholders",
|
||||
expected: []string{"FOO_BAR", "BAZ123"},
|
||||
},
|
||||
{
|
||||
input: "This is a test with no placeholders",
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
input: "This is a test with $FOO appearing twice: $FOO",
|
||||
expected: []string{"FOO"},
|
||||
},
|
||||
{
|
||||
input: "This is a test with $1INVALID placeholder",
|
||||
expected: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
matches := namedArgPattern.FindAllStringSubmatch(tc.input, -1)
|
||||
|
||||
// Extract unique argument names
|
||||
argNames := make([]string, 0)
|
||||
argMap := make(map[string]bool)
|
||||
|
||||
for _, match := range matches {
|
||||
argName := match[1] // Group 1 is the name without $
|
||||
if !argMap[argName] {
|
||||
argMap[argName] = true
|
||||
argNames = append(argNames, argName)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we got the expected number of arguments
|
||||
if len(argNames) != len(tc.expected) {
|
||||
t.Errorf("Expected %d arguments, got %d for input: %s", len(tc.expected), len(argNames), tc.input)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if we got the expected argument names
|
||||
for _, expectedArg := range tc.expected {
|
||||
found := false
|
||||
for _, actualArg := range argNames {
|
||||
if actualArg == expectedArg {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Expected argument %s not found in %v for input: %s", expectedArg, argNames, tc.input)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegexPattern(t *testing.T) {
|
||||
pattern := regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
|
||||
|
||||
validMatches := []string{
|
||||
"$FOO",
|
||||
"$BAR",
|
||||
"$FOO_BAR",
|
||||
"$BAZ123",
|
||||
"$ARGUMENTS",
|
||||
}
|
||||
|
||||
invalidMatches := []string{
|
||||
"$foo",
|
||||
"$1BAR",
|
||||
"$_FOO",
|
||||
"FOO",
|
||||
"$",
|
||||
}
|
||||
|
||||
for _, valid := range validMatches {
|
||||
if !pattern.MatchString(valid) {
|
||||
t.Errorf("Expected %s to match, but it didn't", valid)
|
||||
}
|
||||
}
|
||||
|
||||
for _, invalid := range invalidMatches {
|
||||
if pattern.MatchString(invalid) {
|
||||
t.Errorf("Expected %s not to match, but it did", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"log/slog"
|
||||
|
||||
"github.com/atotto/clipboard"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
@@ -22,7 +25,6 @@ import (
|
||||
"github.com/sst/opencode/internal/tui/styles"
|
||||
"github.com/sst/opencode/internal/tui/theme"
|
||||
"github.com/sst/opencode/internal/tui/util"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -40,6 +42,7 @@ type FilePrickerKeyMap struct {
|
||||
OpenFilePicker key.Binding
|
||||
Esc key.Binding
|
||||
InsertCWD key.Binding
|
||||
Paste key.Binding
|
||||
}
|
||||
|
||||
var filePickerKeyMap = FilePrickerKeyMap{
|
||||
@@ -75,6 +78,10 @@ var filePickerKeyMap = FilePrickerKeyMap{
|
||||
key.WithKeys("i"),
|
||||
key.WithHelp("i", "manual path input"),
|
||||
),
|
||||
Paste: key.NewBinding(
|
||||
key.WithKeys("ctrl+v"),
|
||||
key.WithHelp("ctrl+v", "paste file/directory path"),
|
||||
),
|
||||
}
|
||||
|
||||
type filepickerCmp struct {
|
||||
@@ -213,6 +220,15 @@ func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
f.getCurrentFileBelowCursor()
|
||||
}
|
||||
}
|
||||
case key.Matches(msg, filePickerKeyMap.Paste):
|
||||
if f.cwd.Focused() {
|
||||
val, err := clipboard.ReadAll()
|
||||
if err != nil {
|
||||
slog.Error("failed to read clipboard")
|
||||
return f, cmd
|
||||
}
|
||||
f.cwd.SetValue(f.cwd.Value() + val)
|
||||
}
|
||||
case key.Matches(msg, filePickerKeyMap.OpenFilePicker):
|
||||
f.dirs = readDir(f.cwdDetails.directory, false)
|
||||
f.cursor = 0
|
||||
@@ -303,10 +319,6 @@ func (f *filepickerCmp) View() string {
|
||||
}
|
||||
if file.IsDir() {
|
||||
filename = filename + "/"
|
||||
} else if isExtSupported(file.Name()) {
|
||||
filename = filename
|
||||
} else {
|
||||
filename = filename
|
||||
}
|
||||
|
||||
files = append(files, itemStyle.Padding(0, 1).Render(filename))
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/llm/models"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
"github.com/sst/opencode/internal/tui/layout"
|
||||
"github.com/sst/opencode/internal/tui/styles"
|
||||
"github.com/sst/opencode/internal/tui/theme"
|
||||
@@ -127,7 +126,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.switchProvider(1)
|
||||
}
|
||||
case key.Matches(msg, modelKeys.Enter):
|
||||
status.Info(fmt.Sprintf("selected model: %s", m.models[m.selectedIdx].Name))
|
||||
return m, util.CmdHandler(ModelSelectedMsg{Model: m.models[m.selectedIdx]})
|
||||
case key.Matches(msg, modelKeys.Escape):
|
||||
return m, util.CmdHandler(CloseModelDialogMsg{})
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/diff"
|
||||
"github.com/sst/opencode/internal/llm/tools"
|
||||
"github.com/sst/opencode/internal/permission"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"github.com/sst/opencode/internal/tui/styles"
|
||||
"github.com/sst/opencode/internal/tui/theme"
|
||||
"github.com/sst/opencode/internal/tui/util"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -204,10 +206,19 @@ func (p *permissionDialogCmp) renderHeader() string {
|
||||
Render(fmt.Sprintf(": %s", p.permission.ToolName))
|
||||
|
||||
pathKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Path")
|
||||
|
||||
// Get the current working directory to display relative path
|
||||
relativePath := p.permission.Path
|
||||
if filepath.IsAbs(relativePath) {
|
||||
if cwd, err := filepath.Rel(config.WorkingDirectory(), relativePath); err == nil {
|
||||
relativePath = cwd
|
||||
}
|
||||
}
|
||||
|
||||
pathValue := baseStyle.
|
||||
Foreground(t.Text()).
|
||||
Width(p.width - lipgloss.Width(pathKey)).
|
||||
Render(fmt.Sprintf(": %s", p.permission.Path))
|
||||
Render(fmt.Sprintf(": %s", relativePath))
|
||||
|
||||
headerParts := []string{
|
||||
lipgloss.JoinHorizontal(
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package dialog
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
const (
|
||||
maxToolsDialogWidth = 60
|
||||
maxVisibleTools = 15
|
||||
)
|
||||
|
||||
// ToolsDialog interface for the tools list dialog
|
||||
type ToolsDialog interface {
|
||||
tea.Model
|
||||
layout.Bindings
|
||||
SetTools(tools []string)
|
||||
}
|
||||
|
||||
// ShowToolsDialogMsg is sent to show the tools dialog
|
||||
type ShowToolsDialogMsg struct {
|
||||
Show bool
|
||||
}
|
||||
|
||||
// CloseToolsDialogMsg is sent when the tools dialog is closed
|
||||
type CloseToolsDialogMsg struct{}
|
||||
|
||||
type toolItem struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (t toolItem) Render(selected bool, width int) string {
|
||||
th := theme.CurrentTheme()
|
||||
baseStyle := styles.BaseStyle().
|
||||
Width(width).
|
||||
Background(th.Background())
|
||||
|
||||
if selected {
|
||||
baseStyle = baseStyle.
|
||||
Background(th.Primary()).
|
||||
Foreground(th.Background()).
|
||||
Bold(true)
|
||||
} else {
|
||||
baseStyle = baseStyle.
|
||||
Foreground(th.Text())
|
||||
}
|
||||
|
||||
return baseStyle.Render(t.name)
|
||||
}
|
||||
|
||||
type toolsDialogCmp struct {
|
||||
tools []toolItem
|
||||
width int
|
||||
height int
|
||||
list utilComponents.SimpleList[toolItem]
|
||||
}
|
||||
|
||||
type toolsKeyMap struct {
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Escape key.Binding
|
||||
J key.Binding
|
||||
K key.Binding
|
||||
}
|
||||
|
||||
var toolsKeys = toolsKeyMap{
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("up"),
|
||||
key.WithHelp("↑", "previous tool"),
|
||||
),
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("down"),
|
||||
key.WithHelp("↓", "next tool"),
|
||||
),
|
||||
Escape: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "close"),
|
||||
),
|
||||
J: key.NewBinding(
|
||||
key.WithKeys("j"),
|
||||
key.WithHelp("j", "next tool"),
|
||||
),
|
||||
K: key.NewBinding(
|
||||
key.WithKeys("k"),
|
||||
key.WithHelp("k", "previous tool"),
|
||||
),
|
||||
}
|
||||
|
||||
func (m *toolsDialogCmp) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *toolsDialogCmp) SetTools(tools []string) {
|
||||
var toolItems []toolItem
|
||||
for _, name := range tools {
|
||||
toolItems = append(toolItems, toolItem{name: name})
|
||||
}
|
||||
|
||||
m.tools = toolItems
|
||||
m.list.SetItems(toolItems)
|
||||
}
|
||||
|
||||
func (m *toolsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, toolsKeys.Escape):
|
||||
return m, func() tea.Msg { return CloseToolsDialogMsg{} }
|
||||
// Pass other key messages to the list component
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
listModel, cmd := m.list.Update(msg)
|
||||
m.list = listModel.(utilComponents.SimpleList[toolItem])
|
||||
return m, cmd
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
}
|
||||
|
||||
// For non-key messages
|
||||
var cmd tea.Cmd
|
||||
listModel, cmd := m.list.Update(msg)
|
||||
m.list = listModel.(utilComponents.SimpleList[toolItem])
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *toolsDialogCmp) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.BaseStyle().Background(t.Background())
|
||||
|
||||
title := baseStyle.
|
||||
Foreground(t.Primary()).
|
||||
Bold(true).
|
||||
Width(maxToolsDialogWidth).
|
||||
Padding(0, 0, 1).
|
||||
Render("Available Tools")
|
||||
|
||||
// Calculate dialog width based on content
|
||||
dialogWidth := min(maxToolsDialogWidth, m.width/2)
|
||||
m.list.SetMaxWidth(dialogWidth)
|
||||
|
||||
content := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
title,
|
||||
m.list.View(),
|
||||
)
|
||||
|
||||
return baseStyle.Padding(1, 2).
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderBackground(t.Background()).
|
||||
BorderForeground(t.TextMuted()).
|
||||
Background(t.Background()).
|
||||
Width(lipgloss.Width(content) + 4).
|
||||
Render(content)
|
||||
}
|
||||
|
||||
func (m *toolsDialogCmp) BindingKeys() []key.Binding {
|
||||
return layout.KeyMapToSlice(toolsKeys)
|
||||
}
|
||||
|
||||
func NewToolsDialogCmp() ToolsDialog {
|
||||
list := utilComponents.NewSimpleList[toolItem](
|
||||
[]toolItem{},
|
||||
maxVisibleTools,
|
||||
"No tools available",
|
||||
true,
|
||||
)
|
||||
|
||||
return &toolsDialogCmp{
|
||||
list: list,
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package spinner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// Spinner wraps the bubbles spinner for both interactive and non-interactive mode
|
||||
type Spinner struct {
|
||||
model spinner.Model
|
||||
done chan struct{}
|
||||
prog *tea.Program
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// spinnerModel is the tea.Model for the spinner
|
||||
type spinnerModel struct {
|
||||
spinner spinner.Model
|
||||
message string
|
||||
quitting bool
|
||||
}
|
||||
|
||||
func (m spinnerModel) Init() tea.Cmd {
|
||||
return m.spinner.Tick
|
||||
}
|
||||
|
||||
func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
case spinner.TickMsg:
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
case quitMsg:
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
default:
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m spinnerModel) View() string {
|
||||
if m.quitting {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s %s", m.spinner.View(), m.message)
|
||||
}
|
||||
|
||||
// quitMsg is sent when we want to quit the spinner
|
||||
type quitMsg struct{}
|
||||
|
||||
// NewSpinner creates a new spinner with the given message
|
||||
func NewSpinner(message string) *Spinner {
|
||||
s := spinner.New()
|
||||
s.Spinner = spinner.Dot
|
||||
s.Style = s.Style.Foreground(s.Style.GetForeground())
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
model := spinnerModel{
|
||||
spinner: s,
|
||||
message: message,
|
||||
}
|
||||
|
||||
prog := tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutCatchPanics())
|
||||
|
||||
return &Spinner{
|
||||
model: s,
|
||||
done: make(chan struct{}),
|
||||
prog: prog,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// NewThemedSpinner creates a new spinner with the given message and color
|
||||
func NewThemedSpinner(message string, color lipgloss.AdaptiveColor) *Spinner {
|
||||
s := spinner.New()
|
||||
s.Spinner = spinner.Dot
|
||||
s.Style = s.Style.Foreground(color)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
model := spinnerModel{
|
||||
spinner: s,
|
||||
message: message,
|
||||
}
|
||||
|
||||
prog := tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutCatchPanics())
|
||||
|
||||
return &Spinner{
|
||||
model: s,
|
||||
done: make(chan struct{}),
|
||||
prog: prog,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the spinner animation
|
||||
func (s *Spinner) Start() {
|
||||
go func() {
|
||||
defer close(s.done)
|
||||
go func() {
|
||||
<-s.ctx.Done()
|
||||
s.prog.Send(quitMsg{})
|
||||
}()
|
||||
_, err := s.prog.Run()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error running spinner: %v\n", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop ends the spinner animation
|
||||
func (s *Spinner) Stop() {
|
||||
s.cancel()
|
||||
<-s.done
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package spinner
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSpinner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a spinner
|
||||
s := NewSpinner("Test spinner")
|
||||
|
||||
// Start the spinner
|
||||
s.Start()
|
||||
|
||||
// Wait a bit to let it run
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Stop the spinner
|
||||
s.Stop()
|
||||
|
||||
// If we got here without panicking, the test passes
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//go:build !windows
|
||||
|
||||
package image
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"github.com/atotto/clipboard"
|
||||
)
|
||||
|
||||
func GetImageFromClipboard() ([]byte, string, error) {
|
||||
text, err := clipboard.ReadAll()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("Error reading clipboard")
|
||||
}
|
||||
|
||||
if text == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
binaryData := []byte(text)
|
||||
imageBytes, err := binaryToImage(binaryData)
|
||||
if err != nil {
|
||||
return nil, text, nil
|
||||
}
|
||||
return imageBytes, "", nil
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
func binaryToImage(data []byte) ([]byte, error) {
|
||||
reader := bytes.NewReader(data)
|
||||
img, _, err := image.Decode(reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Unable to covert bytes to image")
|
||||
}
|
||||
|
||||
return ImageToBytes(img)
|
||||
}
|
||||
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//go:build windows
|
||||
|
||||
package image
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"log/slog"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
var (
|
||||
user32 = syscall.NewLazyDLL("user32.dll")
|
||||
kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||
openClipboard = user32.NewProc("OpenClipboard")
|
||||
closeClipboard = user32.NewProc("CloseClipboard")
|
||||
getClipboardData = user32.NewProc("GetClipboardData")
|
||||
isClipboardFormatAvailable = user32.NewProc("IsClipboardFormatAvailable")
|
||||
globalLock = kernel32.NewProc("GlobalLock")
|
||||
globalUnlock = kernel32.NewProc("GlobalUnlock")
|
||||
globalSize = kernel32.NewProc("GlobalSize")
|
||||
)
|
||||
|
||||
const (
|
||||
CF_TEXT = 1
|
||||
CF_UNICODETEXT = 13
|
||||
CF_DIB = 8
|
||||
)
|
||||
|
||||
type BITMAPINFOHEADER struct {
|
||||
BiSize uint32
|
||||
BiWidth int32
|
||||
BiHeight int32
|
||||
BiPlanes uint16
|
||||
BiBitCount uint16
|
||||
BiCompression uint32
|
||||
BiSizeImage uint32
|
||||
BiXPelsPerMeter int32
|
||||
BiYPelsPerMeter int32
|
||||
BiClrUsed uint32
|
||||
BiClrImportant uint32
|
||||
}
|
||||
|
||||
func GetImageFromClipboard() ([]byte, string, error) {
|
||||
ret, _, _ := openClipboard.Call(0)
|
||||
if ret == 0 {
|
||||
return nil, "", fmt.Errorf("failed to open clipboard")
|
||||
}
|
||||
defer func(closeClipboard *syscall.LazyProc, a ...uintptr) {
|
||||
_, _, err := closeClipboard.Call(a...)
|
||||
if err != nil {
|
||||
slog.Error("close clipboard failed")
|
||||
return
|
||||
}
|
||||
}(closeClipboard)
|
||||
isTextAvailable, _, _ := isClipboardFormatAvailable.Call(uintptr(CF_TEXT))
|
||||
isUnicodeTextAvailable, _, _ := isClipboardFormatAvailable.Call(uintptr(CF_UNICODETEXT))
|
||||
|
||||
if isTextAvailable != 0 || isUnicodeTextAvailable != 0 {
|
||||
// Get text from clipboard
|
||||
var formatToUse uintptr = CF_TEXT
|
||||
if isUnicodeTextAvailable != 0 {
|
||||
formatToUse = CF_UNICODETEXT
|
||||
}
|
||||
|
||||
hClipboardText, _, _ := getClipboardData.Call(formatToUse)
|
||||
if hClipboardText != 0 {
|
||||
textPtr, _, _ := globalLock.Call(hClipboardText)
|
||||
if textPtr != 0 {
|
||||
defer func(globalUnlock *syscall.LazyProc, a ...uintptr) {
|
||||
_, _, err := globalUnlock.Call(a...)
|
||||
if err != nil {
|
||||
slog.Error("Global unlock failed")
|
||||
return
|
||||
}
|
||||
}(globalUnlock, hClipboardText)
|
||||
|
||||
// Get clipboard text
|
||||
var clipboardText string
|
||||
if formatToUse == CF_UNICODETEXT {
|
||||
// Convert wide string to Go string
|
||||
clipboardText = syscall.UTF16ToString((*[1 << 20]uint16)(unsafe.Pointer(textPtr))[:])
|
||||
} else {
|
||||
// Get size of ANSI text
|
||||
size, _, _ := globalSize.Call(hClipboardText)
|
||||
if size > 0 {
|
||||
// Convert ANSI string to Go string
|
||||
textBytes := make([]byte, size)
|
||||
copy(textBytes, (*[1 << 20]byte)(unsafe.Pointer(textPtr))[:size:size])
|
||||
clipboardText = bytesToString(textBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the text is not empty
|
||||
if clipboardText != "" {
|
||||
return nil, clipboardText, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
hClipboardData, _, _ := getClipboardData.Call(uintptr(CF_DIB))
|
||||
if hClipboardData == 0 {
|
||||
return nil, "", fmt.Errorf("failed to get clipboard data")
|
||||
}
|
||||
|
||||
dataPtr, _, _ := globalLock.Call(hClipboardData)
|
||||
if dataPtr == 0 {
|
||||
return nil, "", fmt.Errorf("failed to lock clipboard data")
|
||||
}
|
||||
defer func(globalUnlock *syscall.LazyProc, a ...uintptr) {
|
||||
_, _, err := globalUnlock.Call(a...)
|
||||
if err != nil {
|
||||
slog.Error("Global unlock failed")
|
||||
return
|
||||
}
|
||||
}(globalUnlock, hClipboardData)
|
||||
|
||||
bmiHeader := (*BITMAPINFOHEADER)(unsafe.Pointer(dataPtr))
|
||||
|
||||
width := int(bmiHeader.BiWidth)
|
||||
height := int(bmiHeader.BiHeight)
|
||||
if height < 0 {
|
||||
height = -height
|
||||
}
|
||||
bitsPerPixel := int(bmiHeader.BiBitCount)
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
|
||||
var bitsOffset uintptr
|
||||
if bitsPerPixel <= 8 {
|
||||
numColors := uint32(1) << bitsPerPixel
|
||||
if bmiHeader.BiClrUsed > 0 {
|
||||
numColors = bmiHeader.BiClrUsed
|
||||
}
|
||||
bitsOffset = unsafe.Sizeof(*bmiHeader) + uintptr(numColors*4)
|
||||
} else {
|
||||
bitsOffset = unsafe.Sizeof(*bmiHeader)
|
||||
}
|
||||
|
||||
for y := range height {
|
||||
for x := range width {
|
||||
|
||||
srcY := height - y - 1
|
||||
if bmiHeader.BiHeight < 0 {
|
||||
srcY = y
|
||||
}
|
||||
|
||||
var pixelPointer unsafe.Pointer
|
||||
var r, g, b, a uint8
|
||||
|
||||
switch bitsPerPixel {
|
||||
case 24:
|
||||
stride := (width*3 + 3) &^ 3
|
||||
pixelPointer = unsafe.Pointer(dataPtr + bitsOffset + uintptr(srcY*stride+x*3))
|
||||
b = *(*byte)(pixelPointer)
|
||||
g = *(*byte)(unsafe.Add(pixelPointer, 1))
|
||||
r = *(*byte)(unsafe.Add(pixelPointer, 2))
|
||||
a = 255
|
||||
case 32:
|
||||
pixelPointer = unsafe.Pointer(dataPtr + bitsOffset + uintptr(srcY*width*4+x*4))
|
||||
b = *(*byte)(pixelPointer)
|
||||
g = *(*byte)(unsafe.Add(pixelPointer, 1))
|
||||
r = *(*byte)(unsafe.Add(pixelPointer, 2))
|
||||
a = *(*byte)(unsafe.Add(pixelPointer, 3))
|
||||
if a == 0 {
|
||||
a = 255
|
||||
}
|
||||
default:
|
||||
return nil, "", fmt.Errorf("unsupported bit count: %d", bitsPerPixel)
|
||||
}
|
||||
|
||||
img.Set(x, y, color.RGBA{R: r, G: g, B: b, A: a})
|
||||
}
|
||||
}
|
||||
|
||||
imageBytes, err := ImageToBytes(img)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return imageBytes, "", nil
|
||||
}
|
||||
|
||||
func bytesToString(b []byte) string {
|
||||
i := bytes.IndexByte(b, 0)
|
||||
if i == -1 {
|
||||
return string(b)
|
||||
}
|
||||
return string(b[:i])
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"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) {
|
||||
@@ -70,3 +73,13 @@ func ImagePreview(width int, filename string) (string, error) {
|
||||
|
||||
return imageString, nil
|
||||
}
|
||||
|
||||
func ImageToBytes(image image.Image) ([]byte, error) {
|
||||
buf := new(bytes.Buffer)
|
||||
err := png.Encode(buf, image)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
@@ -11,16 +11,16 @@ type Container interface {
|
||||
tea.Model
|
||||
Sizeable
|
||||
Bindings
|
||||
Focus() // Add focus method
|
||||
Blur() // Add blur method
|
||||
Focus()
|
||||
Blur()
|
||||
}
|
||||
|
||||
type container struct {
|
||||
width int
|
||||
height int
|
||||
|
||||
content tea.Model
|
||||
|
||||
// Style options
|
||||
paddingTop int
|
||||
paddingRight int
|
||||
paddingBottom int
|
||||
@@ -32,7 +32,7 @@ type container struct {
|
||||
borderLeft bool
|
||||
borderStyle lipgloss.Border
|
||||
|
||||
focused bool // Track focus state
|
||||
focused bool
|
||||
}
|
||||
|
||||
func (c *container) Init() tea.Cmd {
|
||||
@@ -152,16 +152,13 @@ func (c *container) Blur() {
|
||||
type ContainerOption func(*container)
|
||||
|
||||
func NewContainer(content tea.Model, options ...ContainerOption) Container {
|
||||
|
||||
c := &container{
|
||||
content: content,
|
||||
borderStyle: lipgloss.NormalBorder(),
|
||||
}
|
||||
|
||||
for _, option := range options {
|
||||
option(c)
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package page
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@@ -81,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
|
||||
}
|
||||
|
||||
@@ -202,6 +202,54 @@ func LoadCustomTheme(customTheme map[string]any) (Theme, error) {
|
||||
theme.DiffAddedLineNumberBgColor = adaptiveColor
|
||||
case "diffremovedlinenumberbg":
|
||||
theme.DiffRemovedLineNumberBgColor = adaptiveColor
|
||||
case "syntaxcomment":
|
||||
theme.SyntaxCommentColor = adaptiveColor
|
||||
case "syntaxkeyword":
|
||||
theme.SyntaxKeywordColor = adaptiveColor
|
||||
case "syntaxfunction":
|
||||
theme.SyntaxFunctionColor = adaptiveColor
|
||||
case "syntaxvariable":
|
||||
theme.SyntaxVariableColor = adaptiveColor
|
||||
case "syntaxstring":
|
||||
theme.SyntaxStringColor = adaptiveColor
|
||||
case "syntaxnumber":
|
||||
theme.SyntaxNumberColor = adaptiveColor
|
||||
case "syntaxtype":
|
||||
theme.SyntaxTypeColor = adaptiveColor
|
||||
case "syntaxoperator":
|
||||
theme.SyntaxOperatorColor = adaptiveColor
|
||||
case "syntaxpunctuation":
|
||||
theme.SyntaxPunctuationColor = adaptiveColor
|
||||
case "markdowntext":
|
||||
theme.MarkdownTextColor = adaptiveColor
|
||||
case "markdownheading":
|
||||
theme.MarkdownHeadingColor = adaptiveColor
|
||||
case "markdownlink":
|
||||
theme.MarkdownLinkColor = adaptiveColor
|
||||
case "markdownlinktext":
|
||||
theme.MarkdownLinkTextColor = adaptiveColor
|
||||
case "markdowncode":
|
||||
theme.MarkdownCodeColor = adaptiveColor
|
||||
case "markdownblockquote":
|
||||
theme.MarkdownBlockQuoteColor = adaptiveColor
|
||||
case "markdownemph":
|
||||
theme.MarkdownEmphColor = adaptiveColor
|
||||
case "markdownstrong":
|
||||
theme.MarkdownStrongColor = adaptiveColor
|
||||
case "markdownhorizontalrule":
|
||||
theme.MarkdownHorizontalRuleColor = adaptiveColor
|
||||
case "markdownlistitem":
|
||||
theme.MarkdownListItemColor = adaptiveColor
|
||||
case "markdownlistitemenum":
|
||||
theme.MarkdownListEnumerationColor = adaptiveColor
|
||||
case "markdownimage":
|
||||
theme.MarkdownImageColor = adaptiveColor
|
||||
case "markdownimagetext":
|
||||
theme.MarkdownImageTextColor = adaptiveColor
|
||||
case "markdowncodeblock":
|
||||
theme.MarkdownCodeBlockColor = adaptiveColor
|
||||
case "markdownlistenumeration":
|
||||
theme.MarkdownListEnumerationColor = adaptiveColor
|
||||
default:
|
||||
slog.Warn("Unknown color key in custom theme", "key", key)
|
||||
}
|
||||
|
||||
@@ -235,7 +235,19 @@ func ParseAdaptiveColor(value any) (lipgloss.AdaptiveColor, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Case 2: Map with dark and light keys
|
||||
// Case 2: Int value between 0 and 255
|
||||
if numericVal, ok := value.(float64); ok {
|
||||
intVal := int(numericVal)
|
||||
if intVal < 0 || intVal > 255 {
|
||||
return lipgloss.AdaptiveColor{}, fmt.Errorf("invalid int color value (must be between 0 and 255): %d", intVal)
|
||||
}
|
||||
return lipgloss.AdaptiveColor{
|
||||
Dark: fmt.Sprintf("%d", intVal),
|
||||
Light: fmt.Sprintf("%d", intVal),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Case 3: Map with dark and light keys
|
||||
if colorMap, ok := value.(map[string]any); ok {
|
||||
darkVal, darkOk := colorMap["dark"]
|
||||
lightVal, lightOk := colorMap["light"]
|
||||
@@ -248,7 +260,20 @@ func ParseAdaptiveColor(value any) (lipgloss.AdaptiveColor, error) {
|
||||
lightHex, lightIsString := lightVal.(string)
|
||||
|
||||
if !darkIsString || !lightIsString {
|
||||
return lipgloss.AdaptiveColor{}, fmt.Errorf("color values must be strings")
|
||||
darkVal, darkIsNumber := darkVal.(float64)
|
||||
lightVal, lightIsNumber := lightVal.(float64)
|
||||
|
||||
if !darkIsNumber || !lightIsNumber {
|
||||
return lipgloss.AdaptiveColor{}, fmt.Errorf("color map values must be strings or ints")
|
||||
}
|
||||
|
||||
darkInt := int(darkVal)
|
||||
lightInt := int(lightVal)
|
||||
|
||||
return lipgloss.AdaptiveColor{
|
||||
Dark: fmt.Sprintf("%d", darkInt),
|
||||
Light: fmt.Sprintf("%d", lightInt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
if !hexColorRegex.MatchString(darkHex) || !hexColorRegex.MatchString(lightHex) {
|
||||
|
||||
+157
-27
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/llm/agent"
|
||||
"github.com/sst/opencode/internal/logging"
|
||||
"github.com/sst/opencode/internal/message"
|
||||
"github.com/sst/opencode/internal/permission"
|
||||
@@ -38,6 +39,7 @@ type keyMap struct {
|
||||
Filepicker key.Binding
|
||||
Models key.Binding
|
||||
SwitchTheme key.Binding
|
||||
Tools key.Binding
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -81,6 +83,11 @@ var keys = keyMap{
|
||||
key.WithKeys("ctrl+t"),
|
||||
key.WithHelp("ctrl+t", "switch theme"),
|
||||
),
|
||||
|
||||
Tools: key.NewBinding(
|
||||
key.WithKeys("f9"),
|
||||
key.WithHelp("f9", "show available tools"),
|
||||
),
|
||||
}
|
||||
|
||||
var helpEsc = key.NewBinding(
|
||||
@@ -135,8 +142,11 @@ type appModel struct {
|
||||
showThemeDialog bool
|
||||
themeDialog dialog.ThemeDialog
|
||||
|
||||
showArgumentsDialog bool
|
||||
argumentsDialog dialog.ArgumentsDialogCmp
|
||||
showMultiArgumentsDialog bool
|
||||
multiArgumentsDialog dialog.MultiArgumentsDialogCmp
|
||||
|
||||
showToolsDialog bool
|
||||
toolsDialog dialog.ToolsDialog
|
||||
}
|
||||
|
||||
func (a appModel) Init() tea.Cmd {
|
||||
@@ -162,6 +172,8 @@ func (a appModel) Init() tea.Cmd {
|
||||
cmds = append(cmds, cmd)
|
||||
cmd = a.themeDialog.Init()
|
||||
cmds = append(cmds, cmd)
|
||||
cmd = a.toolsDialog.Init()
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
// Check if we should show the init dialog
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
@@ -196,7 +208,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 +238,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...)
|
||||
@@ -287,6 +299,14 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case dialog.CloseThemeDialogMsg:
|
||||
a.showThemeDialog = false
|
||||
return a, nil
|
||||
|
||||
case dialog.CloseToolsDialogMsg:
|
||||
a.showToolsDialog = false
|
||||
return a, nil
|
||||
|
||||
case dialog.ShowToolsDialogMsg:
|
||||
a.showToolsDialog = msg.Show
|
||||
return a, nil
|
||||
|
||||
case dialog.ThemeChangedMsg:
|
||||
a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
|
||||
@@ -346,33 +366,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,12 +421,21 @@ 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
|
||||
}
|
||||
if a.showToolsDialog {
|
||||
a.showToolsDialog = false
|
||||
}
|
||||
return a, nil
|
||||
case key.Matches(msg, keys.SwitchSession):
|
||||
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
|
||||
// Close other dialogs
|
||||
a.showToolsDialog = false
|
||||
a.showThemeDialog = false
|
||||
a.showModelDialog = false
|
||||
a.showFilepicker = false
|
||||
|
||||
// Load sessions and show the dialog
|
||||
sessions, err := a.app.Sessions.List(context.Background())
|
||||
if err != nil {
|
||||
@@ -418,6 +453,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return a, nil
|
||||
case key.Matches(msg, keys.Commands):
|
||||
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog && !a.showFilepicker {
|
||||
// Close other dialogs
|
||||
a.showToolsDialog = false
|
||||
a.showModelDialog = false
|
||||
|
||||
// Show commands dialog
|
||||
if len(a.commands) == 0 {
|
||||
status.Warn("No commands available")
|
||||
@@ -434,22 +473,52 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return a, nil
|
||||
}
|
||||
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
|
||||
// Close other dialogs
|
||||
a.showToolsDialog = false
|
||||
a.showThemeDialog = false
|
||||
a.showFilepicker = false
|
||||
|
||||
a.showModelDialog = true
|
||||
return a, nil
|
||||
}
|
||||
return a, nil
|
||||
case key.Matches(msg, keys.SwitchTheme):
|
||||
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
|
||||
// Close other dialogs
|
||||
a.showToolsDialog = false
|
||||
a.showModelDialog = false
|
||||
a.showFilepicker = false
|
||||
|
||||
a.showThemeDialog = true
|
||||
return a, a.themeDialog.Init()
|
||||
}
|
||||
return a, nil
|
||||
case key.Matches(msg, keys.Tools):
|
||||
// Check if any other dialog is open
|
||||
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions &&
|
||||
!a.showSessionDialog && !a.showCommandDialog && !a.showThemeDialog &&
|
||||
!a.showFilepicker && !a.showModelDialog && !a.showInitDialog &&
|
||||
!a.showMultiArgumentsDialog {
|
||||
// Toggle tools dialog
|
||||
a.showToolsDialog = !a.showToolsDialog
|
||||
if a.showToolsDialog {
|
||||
// Get tool names dynamically
|
||||
toolNames := getAvailableToolNames(a.app)
|
||||
a.toolsDialog.SetTools(toolNames)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
return a, nil
|
||||
case key.Matches(msg, returnKey) || key.Matches(msg):
|
||||
if msg.String() == quitKey {
|
||||
if a.currentPage == page.LogsPage {
|
||||
return a, a.moveToPage(page.ChatPage)
|
||||
}
|
||||
} else if !a.filepicker.IsCWDFocused() {
|
||||
if a.showToolsDialog {
|
||||
a.showToolsDialog = false
|
||||
return a, nil
|
||||
}
|
||||
if a.showQuit {
|
||||
a.showQuit = !a.showQuit
|
||||
return a, nil
|
||||
@@ -484,6 +553,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return a, nil
|
||||
}
|
||||
a.showHelp = !a.showHelp
|
||||
|
||||
// Close other dialogs if opening help
|
||||
if a.showHelp {
|
||||
a.showToolsDialog = false
|
||||
}
|
||||
return a, nil
|
||||
case key.Matches(msg, helpEsc):
|
||||
if a.app.PrimaryAgent.IsBusy() {
|
||||
@@ -494,8 +568,18 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return a, nil
|
||||
}
|
||||
case key.Matches(msg, keys.Filepicker):
|
||||
// Toggle filepicker
|
||||
a.showFilepicker = !a.showFilepicker
|
||||
a.filepicker.ToggleFilepicker(a.showFilepicker)
|
||||
|
||||
// Close other dialogs if opening filepicker
|
||||
if a.showFilepicker {
|
||||
a.showToolsDialog = false
|
||||
a.showThemeDialog = false
|
||||
a.showModelDialog = false
|
||||
a.showCommandDialog = false
|
||||
a.showSessionDialog = false
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -594,6 +678,16 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return a, tea.Batch(cmds...)
|
||||
}
|
||||
}
|
||||
|
||||
if a.showToolsDialog {
|
||||
d, toolsCmd := a.toolsDialog.Update(msg)
|
||||
a.toolsDialog = d.(dialog.ToolsDialog)
|
||||
cmds = append(cmds, toolsCmd)
|
||||
// Only block key messages send all other messages down
|
||||
if _, ok := msg.(tea.KeyMsg); ok {
|
||||
return a, tea.Batch(cmds...)
|
||||
}
|
||||
}
|
||||
|
||||
s, cmd := a.status.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
@@ -609,6 +703,26 @@ func (a *appModel) RegisterCommand(cmd dialog.Command) {
|
||||
a.commands = append(a.commands, cmd)
|
||||
}
|
||||
|
||||
// getAvailableToolNames returns a list of all available tool names
|
||||
func getAvailableToolNames(app *app.App) []string {
|
||||
// Get primary agent tools (which already include MCP tools)
|
||||
allTools := agent.PrimaryAgentTools(
|
||||
app.Permissions,
|
||||
app.Sessions,
|
||||
app.Messages,
|
||||
app.History,
|
||||
app.LSPClients,
|
||||
)
|
||||
|
||||
// Extract tool names
|
||||
var toolNames []string
|
||||
for _, tool := range allTools {
|
||||
toolNames = append(toolNames, tool.Info().Name)
|
||||
}
|
||||
|
||||
return toolNames
|
||||
}
|
||||
|
||||
func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
|
||||
// Allow navigating to logs page even when agent is busy
|
||||
if a.app.PrimaryAgent.IsBusy() && pageID != page.LogsPage {
|
||||
@@ -800,8 +914,23 @@ 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
|
||||
col -= lipgloss.Width(overlay) / 2
|
||||
appView = layout.PlaceOverlay(
|
||||
col,
|
||||
row,
|
||||
overlay,
|
||||
appView,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
if a.showToolsDialog {
|
||||
overlay := a.toolsDialog.View()
|
||||
row := lipgloss.Height(appView) / 2
|
||||
row -= lipgloss.Height(overlay) / 2
|
||||
col := lipgloss.Width(appView) / 2
|
||||
@@ -832,6 +961,7 @@ func New(app *app.App) tea.Model {
|
||||
permissions: dialog.NewPermissionDialogCmp(),
|
||||
initDialog: dialog.NewInitDialogCmp(),
|
||||
themeDialog: dialog.NewThemeDialogCmp(),
|
||||
toolsDialog: dialog.NewToolsDialogCmp(),
|
||||
app: app,
|
||||
commands: []dialog.Command{},
|
||||
pages: map[page.PageID]tea.Model{
|
||||
|
||||
+128
-105
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user