Compare commits

..
37 Commits
Author SHA1 Message Date
Dax Raad 2b258b1473 switch default to claude sonnet 4
release / goreleaser (push) Has been cancelled
2025-05-22 13:17:32 -04:00
Alvin Johansson dff8e77eb6 docs: add clearer bedrock instructions (#40) 2025-05-20 08:23:51 -05:00
Ed Zynda 6e854a4df4 fix: disable history navigation when filepicker is open (#39) 2025-05-18 18:41:34 -05:00
phantomreactor 2f8984fadb Codex mini (#34) 2025-05-18 08:29:53 -05:00
Ed Zynda c84918cb47 feat: Add message history navigation with arrow keys (#30) 2025-05-17 13:35:49 -05:00
Pierre B. 05bb065d00 fix: log pane text wrapping (#32) 2025-05-17 13:33:47 -05:00
Pierre Berube 3742997889 fix: typo 2025-05-17 08:50:30 -05:00
Pierre Berube daf0305203 fix: split out bedrock models 2025-05-17 08:48:51 -05:00
Ed Zynda 307982a099 feat: Add tool restriction flags for non-interactive mode (#29)
release / goreleaser (push) Has been cancelled
2025-05-17 08:23:13 -05:00
phantomreactor ba416e787b paste images with ctrl+v (#26) 2025-05-16 14:31:50 -05:00
Ed Zynda b71cae63f1 feat: Add tools dialog accessible via F9 (#24)
* Add tools dialog

* Remove sorting and double items

* Update key handling
2025-05-16 10:57:35 -05:00
Ed Zynda c92f7c6630 fix: Show correct file paths in permission window (#25)
* Fix paths in permission window to show relative paths

* Fix paths in permission window to show actual file paths
2025-05-16 10:56:39 -05:00
Ed Zynda 4a444e9c9b feat: Make shell configurable via config file (#23) 2025-05-16 06:27:28 -05:00
Ed Zynda 623d132772 feat: Add non-interactive mode (#18) 2025-05-16 06:06:28 -05:00
adamdottv d127a1c4eb feat: 0-255 color support in custom themes 2025-05-15 19:02:27 -05:00
adamdottv c9cca48d08 fix: layout
release / goreleaser (push) Has been cancelled
2025-05-15 15:57:15 -05:00
adamdottv 3944930fc0 chore: cleanup 2025-05-15 15:45:22 -05:00
adamdottv 825c0b64af fix: build 2025-05-15 14:49:30 -05:00
adamdottv d7af7dd3fe fix: redundant status msg 2025-05-15 14:42:10 -05:00
adamdottv b112216241 fix: build 2025-05-15 13:36:58 -05:00
mineo 87237b6462 feat: support VertexAI provider (#153)
* support: vertexai

fix

fix

set default for vertexai

added comment

fix

fix

* create schema

* fix README.md

* fix order

* added pupularity

* set tools if tools is exists

restore commentout

* fix comment

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

* fix: Fix styling in multi-arguments dialog

* Remove old unused modal

* Focus on only one input at a time
2025-05-15 09:43:33 -05:00
Adictya a203fb8ccc fix(complete-module): change completion start key to slash
release / goreleaser (push) Has been cancelled
2025-05-15 08:29:54 -05:00
Adictya acc084c9ea chore(complete-module): lint 2025-05-15 08:29:54 -05:00
Adictya 3ee213081e fix(complete-module): logging 2025-05-15 08:29:54 -05:00
Adictya 15bf40bc10 feat(complete-module): add completions logic, dialog and providers 2025-05-15 08:29:54 -05:00
adamdottv a33e3e25b6 chore: cleanup dead code 2025-05-15 07:19:17 -05:00
adamdottv 658faab2bf chore: update icon in readme 2025-05-15 07:03:58 -05:00
rekram1-node 797045ee29 feat: add configuration persistence for model selections 2025-05-14 16:32:05 -05:00
adamdottv c8f8d67a88 feat: codeAction tool
release / goreleaser (push) Has been cancelled
2025-05-14 14:57:47 -05:00
Dax Raad 182e32e4f7 add screenshot 2025-05-14 15:50:25 -04:00
67 changed files with 4964 additions and 1101 deletions
+2 -1
View File
@@ -42,4 +42,5 @@ Thumbs.db
.env.local .env.local
.opencode/ .opencode/
# ignore locally built binary
opencode*
+145 -11
View File
@@ -1,4 +1,6 @@
# OpenCode # OpenCode
![OpenCode Terminal UI](screenshot.png)
> **⚠️ Early Development Notice:** This project is in early development and is not yet ready for production use. Features may change, break, or be incomplete. Use at your own risk. > **⚠️ Early Development Notice:** This project is in early development and is not yet ready for production use. Features may change, break, or be incomplete. Use at your own risk.
@@ -19,6 +21,7 @@ OpenCode is a Go-based CLI application that brings AI assistance to your termina
- **LSP Integration**: Language Server Protocol support for code intelligence - **LSP Integration**: Language Server Protocol support for code intelligence
- **File Change Tracking**: Track and visualize file changes during sessions - **File Change Tracking**: Track and visualize file changes during sessions
- **External Editor Support**: Open your preferred editor for composing messages - **External Editor Support**: Open your preferred editor for composing messages
- **Named Arguments for Custom Commands**: Create powerful custom commands with multiple named placeholders
## Installation ## Installation
@@ -71,6 +74,8 @@ You can configure OpenCode using environment variables:
| `ANTHROPIC_API_KEY` | For Claude models | | `ANTHROPIC_API_KEY` | For Claude models |
| `OPENAI_API_KEY` | For OpenAI models | | `OPENAI_API_KEY` | For OpenAI models |
| `GEMINI_API_KEY` | For Google Gemini 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 | | `GROQ_API_KEY` | For Groq models |
| `AWS_ACCESS_KEY_ID` | For AWS Bedrock (Claude) | | `AWS_ACCESS_KEY_ID` | For AWS Bedrock (Claude) |
| `AWS_SECRET_ACCESS_KEY` | For AWS Bedrock (Claude) | | `AWS_SECRET_ACCESS_KEY` | For AWS Bedrock (Claude) |
@@ -78,7 +83,6 @@ You can configure OpenCode using environment variables:
| `AZURE_OPENAI_ENDPOINT` | For Azure OpenAI models | | `AZURE_OPENAI_ENDPOINT` | For Azure OpenAI models |
| `AZURE_OPENAI_API_KEY` | For Azure OpenAI models (optional when using Entra ID) | | `AZURE_OPENAI_API_KEY` | For Azure OpenAI models (optional when using Entra ID) |
| `AZURE_OPENAI_API_VERSION` | For Azure OpenAI models | | `AZURE_OPENAI_API_VERSION` | For Azure OpenAI models |
### Configuration File Structure ### Configuration File Structure
```json ```json
@@ -132,6 +136,10 @@ You can configure OpenCode using environment variables:
"command": "gopls" "command": "gopls"
} }
}, },
"shell": {
"path": "/bin/zsh",
"args": ["-l"]
},
"debug": false, "debug": false,
"debugLSP": false "debugLSP": false
} }
@@ -186,7 +194,43 @@ OpenCode supports a variety of AI models from different providers:
- O3 family (o3, o3-mini) - O3 family (o3, o3-mini)
- O4 Mini - O4 Mini
## Usage ### Google Cloud VertexAI
- Gemini 2.5
- Gemini 2.5 Flash
## Using Bedrock Models
To use bedrock models with OpenCode you need three things.
1. Valid AWS credentials (the env vars: `AWS_SECRET_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and `AWS_REGION`)
2. Access to the corresponding model in AWS Bedrock in your region.
a. You can request access in the AWS console on the Bedrock -> "Model access" page.
3. A correct configuration file. You don't need the `providers` key. Instead you have to prefix your models per agent with `bedrock.` and then a valid model. For now only Claude 3.7 is supported.
```json
{
"agents": {
"primary": {
"model": "bedrock.claude-3.7-sonnet",
"maxTokens": 5000,
"reasoningEffort": ""
},
"task": {
"model": "bedrock.claude-3.7-sonnet",
"maxTokens": 5000,
"reasoningEffort": ""
},
"title": {
"model": "bedrock.claude-3.7-sonnet",
"maxTokens": 80,
"reasoningEffort": ""
}
},
}
```
## Interactive Mode Usage
```bash ```bash
# Start OpenCode # Start OpenCode
@@ -199,13 +243,65 @@ opencode -d
opencode -c /path/to/project 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 ## Command-line Flags
| Flag | Short | Description | | Flag | Short | Description |
| --------- | ----- | ----------------------------- | | ----------------- | ----- | ---------------------------------------------------------- |
| `--help` | `-h` | Display help information | | `--help` | `-h` | Display help information |
| `--debug` | `-d` | Enable debug mode | | `--debug` | `-d` | Enable debug mode |
| `--cwd` | `-c` | Set current working directory | | `--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 ## Keyboard Shortcuts
@@ -371,6 +467,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. 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 ## Architecture
OpenCode is built with a modular architecture: OpenCode is built with a modular architecture:
@@ -426,13 +551,22 @@ This creates a command called `user:prime-context`.
### Command Arguments ### 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 ```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 ### Organizing Commands
+292
View File
@@ -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
View File
@@ -15,6 +15,7 @@ import (
"github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/config" "github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/db" "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/agent"
"github.com/sst/opencode/internal/logging" "github.com/sst/opencode/internal/logging"
"github.com/sst/opencode/internal/lsp/discovery" "github.com/sst/opencode/internal/lsp/discovery"
@@ -88,6 +89,25 @@ to assist developers in writing, debugging, and understanding code directly from
return err 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 // Run LSP auto-discovery
if err := discovery.IntegrateLSPServers(cwd); err != nil { if err := discovery.IntegrateLSPServers(cwd); err != nil {
slog.Warn("Failed to auto-discover LSP servers", "error", err) 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("version", "v", false, "Version")
rootCmd.Flags().BoolP("debug", "d", false, "Debug") rootCmd.Flags().BoolP("debug", "d", false, "Debug")
rootCmd.Flags().StringP("cwd", "c", "", "Current working directory") 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")
} }
+1
View File
@@ -227,6 +227,7 @@ func generateSchema() map[string]any {
string(models.ProviderOpenRouter), string(models.ProviderOpenRouter),
string(models.ProviderBedrock), string(models.ProviderBedrock),
string(models.ProviderAzure), string(models.ProviderAzure),
string(models.ProviderVertexAI),
} }
providerSchema["additionalProperties"].(map[string]any)["properties"].(map[string]any)["provider"] = map[string]any{ providerSchema["additionalProperties"].(map[string]any)["properties"].(map[string]any)["provider"] = map[string]any{
+10 -4
View File
@@ -11,7 +11,7 @@ require (
github.com/aymanbagabas/go-udiff v0.2.0 github.com/aymanbagabas/go-udiff v0.2.0
github.com/bmatcuk/doublestar/v4 v4.8.1 github.com/bmatcuk/doublestar/v4 v4.8.1
github.com/catppuccin/go v0.3.0 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/bubbletea v1.3.4
github.com/charmbracelet/glamour v0.9.1 github.com/charmbracelet/glamour v0.9.1
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
@@ -19,13 +19,14 @@ require (
github.com/fsnotify/fsnotify v1.8.0 github.com/fsnotify/fsnotify v1.8.0
github.com/go-logfmt/logfmt v0.6.0 github.com/go-logfmt/logfmt v0.6.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/lithammer/fuzzysearch v1.1.8
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
github.com/mark3labs/mcp-go v0.17.0 github.com/mark3labs/mcp-go v0.17.0
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
github.com/muesli/reflow v0.3.0 github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.16.0 github.com/muesli/termenv v0.16.0
github.com/ncruces/go-sqlite3 v0.25.0 github.com/ncruces/go-sqlite3 v0.25.0
github.com/openai/openai-go v0.1.0-beta.2 github.com/openai/openai-go v0.1.0-beta.10
github.com/pressly/goose/v3 v3.24.2 github.com/pressly/goose/v3 v3.24.2
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
github.com/spf13/cobra v1.9.1 github.com/spf13/cobra v1.9.1
@@ -33,6 +34,11 @@ require (
github.com/stretchr/testify v1.10.0 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 ( require (
cloud.google.com/go v0.116.0 // indirect cloud.google.com/go v0.116.0 // indirect
cloud.google.com/go/auth v0.13.0 // indirect cloud.google.com/go/auth v0.13.0 // indirect
@@ -41,7 +47,7 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect 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/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect
github.com/andybalholm/cascadia v1.3.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 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/aws/protocol/eventstream v1.6.3 // indirect
github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect
@@ -114,7 +120,7 @@ require (
go.opentelemetry.io/otel/trace v1.35.0 // indirect go.opentelemetry.io/otel/trace v1.35.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.37.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/net v0.39.0 // indirect
golang.org/x/sync v0.13.0 // indirect golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect golang.org/x/sys v0.32.0 // indirect
+10 -6
View File
@@ -68,8 +68,8 @@ github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= 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/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= 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 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
@@ -78,12 +78,14 @@ github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ
github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk= 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 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 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 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 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 h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 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-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
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/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 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= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -144,6 +146,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms= github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms=
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231/go.mod h1:S5etECMx+sZnW0Gm100Ma9J1PgVCTgNyFaqGu2b08b4= github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231/go.mod h1:S5etECMx+sZnW0Gm100Ma9J1PgVCTgNyFaqGu2b08b4=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
@@ -175,8 +179,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g= github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
github.com/openai/openai-go v0.1.0-beta.2 h1:Ra5nCFkbEl9w+UJwAciC4kqnIBUCcJazhmMA0/YN894= github.com/openai/openai-go v0.1.0-beta.10 h1:CknhGXe8aXQMRuqg255PFnWzgRY9nEryMxoNIBBM9tU=
github.com/openai/openai-go v0.1.0-beta.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= github.com/openai/openai-go v0.1.0-beta.10/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+15
View File
@@ -10,6 +10,7 @@ import (
"log/slog" "log/slog"
"github.com/sst/opencode/internal/config" "github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/fileutil"
"github.com/sst/opencode/internal/history" "github.com/sst/opencode/internal/history"
"github.com/sst/opencode/internal/llm/agent" "github.com/sst/opencode/internal/llm/agent"
"github.com/sst/opencode/internal/logging" "github.com/sst/opencode/internal/logging"
@@ -39,6 +40,9 @@ type App struct {
watcherCancelFuncs []context.CancelFunc watcherCancelFuncs []context.CancelFunc
cancelFuncsMutex sync.Mutex cancelFuncsMutex sync.Mutex
watcherWG sync.WaitGroup watcherWG sync.WaitGroup
// UI state
filepickerOpen bool
} }
func New(ctx context.Context, conn *sql.DB) (*App, error) { func New(ctx context.Context, conn *sql.DB) (*App, error) {
@@ -72,6 +76,7 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
slog.Error("Failed to initialize status service", "error", err) slog.Error("Failed to initialize status service", "error", err)
return nil, err return nil, err
} }
fileutil.Init()
app := &App{ app := &App{
CurrentSession: &session.Session{}, CurrentSession: &session.Session{},
@@ -126,6 +131,16 @@ func (app *App) initTheme() {
} }
} }
// IsFilepickerOpen returns whether the filepicker is currently open
func (app *App) IsFilepickerOpen() bool {
return app.filepickerOpen
}
// SetFilepickerOpen sets the state of the filepicker
func (app *App) SetFilepickerOpen(open bool) {
app.filepickerOpen = open
}
// Shutdown performs a clean shutdown of the application // Shutdown performs a clean shutdown of the application
func (app *App) Shutdown() { func (app *App) Shutdown() {
// Cancel all watcher goroutines // Cancel all watcher goroutines
+191
View File
@@ -0,0 +1,191 @@
package completions
import (
"bytes"
"fmt"
"os/exec"
"path/filepath"
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/sst/opencode/internal/fileutil"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/tui/components/dialog"
)
type filesAndFoldersContextGroup struct {
prefix string
}
func (cg *filesAndFoldersContextGroup) GetId() string {
return cg.prefix
}
func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI {
return dialog.NewCompletionItem(dialog.CompletionItem{
Title: "Files & Folders",
Value: "files",
})
}
func processNullTerminatedOutput(outputBytes []byte) []string {
if len(outputBytes) > 0 && outputBytes[len(outputBytes)-1] == 0 {
outputBytes = outputBytes[:len(outputBytes)-1]
}
if len(outputBytes) == 0 {
return []string{}
}
split := bytes.Split(outputBytes, []byte{0})
matches := make([]string, 0, len(split))
for _, p := range split {
if len(p) == 0 {
continue
}
path := string(p)
path = filepath.Join(".", path)
if !fileutil.SkipHidden(path) {
matches = append(matches, path)
}
}
return matches
}
func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) {
cmdRg := fileutil.GetRgCmd("") // No glob pattern for this use case
cmdFzf := fileutil.GetFzfCmd(query)
var matches []string
// Case 1: Both rg and fzf available
if cmdRg != nil && cmdFzf != nil {
rgPipe, err := cmdRg.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("failed to get rg stdout pipe: %w", err)
}
defer rgPipe.Close()
cmdFzf.Stdin = rgPipe
var fzfOut bytes.Buffer
var fzfErr bytes.Buffer
cmdFzf.Stdout = &fzfOut
cmdFzf.Stderr = &fzfErr
if err := cmdFzf.Start(); err != nil {
return nil, fmt.Errorf("failed to start fzf: %w", err)
}
errRg := cmdRg.Run()
errFzf := cmdFzf.Wait()
if errRg != nil {
status.Warn(fmt.Sprintf("rg command failed during pipe: %v", errRg))
}
if errFzf != nil {
if exitErr, ok := errFzf.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return []string{}, nil // No matches from fzf
}
return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", errFzf, fzfErr.String())
}
matches = processNullTerminatedOutput(fzfOut.Bytes())
// Case 2: Only rg available
} else if cmdRg != nil {
status.Debug("Using Ripgrep with fuzzy match fallback for file completions")
var rgOut bytes.Buffer
var rgErr bytes.Buffer
cmdRg.Stdout = &rgOut
cmdRg.Stderr = &rgErr
if err := cmdRg.Run(); err != nil {
return nil, fmt.Errorf("rg command failed: %w\nStderr: %s", err, rgErr.String())
}
allFiles := processNullTerminatedOutput(rgOut.Bytes())
matches = fuzzy.Find(query, allFiles)
// Case 3: Only fzf available
} else if cmdFzf != nil {
status.Debug("Using FZF with doublestar fallback for file completions")
files, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0)
if err != nil {
return nil, fmt.Errorf("failed to list files for fzf: %w", err)
}
allFiles := make([]string, 0, len(files))
for _, file := range files {
if !fileutil.SkipHidden(file) {
allFiles = append(allFiles, file)
}
}
var fzfIn bytes.Buffer
for _, file := range allFiles {
fzfIn.WriteString(file)
fzfIn.WriteByte(0)
}
cmdFzf.Stdin = &fzfIn
var fzfOut bytes.Buffer
var fzfErr bytes.Buffer
cmdFzf.Stdout = &fzfOut
cmdFzf.Stderr = &fzfErr
if err := cmdFzf.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return []string{}, nil
}
return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", err, fzfErr.String())
}
matches = processNullTerminatedOutput(fzfOut.Bytes())
// Case 4: Fallback to doublestar with fuzzy match
} else {
status.Debug("Using doublestar with fuzzy match for file completions")
allFiles, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0)
if err != nil {
return nil, fmt.Errorf("failed to glob files: %w", err)
}
filteredFiles := make([]string, 0, len(allFiles))
for _, file := range allFiles {
if !fileutil.SkipHidden(file) {
filteredFiles = append(filteredFiles, file)
}
}
matches = fuzzy.Find(query, filteredFiles)
}
return matches, nil
}
func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
matches, err := cg.getFiles(query)
if err != nil {
return nil, err
}
items := make([]dialog.CompletionItemI, 0, len(matches))
for _, file := range matches {
item := dialog.NewCompletionItem(dialog.CompletionItem{
Title: file,
Value: file,
})
items = append(items, item)
}
return items, nil
}
func NewFileAndFolderContextGroup() dialog.CompletionProvider {
return &filesAndFoldersContextGroup{
prefix: "file",
}
}
+114 -55
View File
@@ -56,7 +56,7 @@ type Provider struct {
// Data defines storage configuration. // Data defines storage configuration.
type Data struct { type Data struct {
Directory string `json:"directory"` Directory string `json:"directory,omitempty"`
} }
// LSPConfig defines configuration for Language Server Protocol integration. // LSPConfig defines configuration for Language Server Protocol integration.
@@ -73,6 +73,12 @@ type TUIConfig struct {
CustomTheme map[string]any `json:"customTheme,omitempty"` 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. // Config is the main configuration structure for the application.
type Config struct { type Config struct {
Data Data `json:"data"` Data Data `json:"data"`
@@ -80,11 +86,12 @@ type Config struct {
MCPServers map[string]MCPServer `json:"mcpServers,omitempty"` MCPServers map[string]MCPServer `json:"mcpServers,omitempty"`
Providers map[models.ModelProvider]Provider `json:"providers,omitempty"` Providers map[models.ModelProvider]Provider `json:"providers,omitempty"`
LSP map[string]LSPConfig `json:"lsp,omitempty"` LSP map[string]LSPConfig `json:"lsp,omitempty"`
Agents map[AgentName]Agent `json:"agents"` Agents map[AgentName]Agent `json:"agents,omitempty"`
Debug bool `json:"debug,omitempty"` Debug bool `json:"debug,omitempty"`
DebugLSP bool `json:"debugLSP,omitempty"` DebugLSP bool `json:"debugLSP,omitempty"`
ContextPaths []string `json:"contextPaths,omitempty"` ContextPaths []string `json:"contextPaths,omitempty"`
TUI TUIConfig `json:"tui"` TUI TUIConfig `json:"tui"`
Shell ShellConfig `json:"shell,omitempty"`
} }
// Application constants // Application constants
@@ -235,12 +242,13 @@ func setProviderDefaults() {
// 5. OpenRouter // 5. OpenRouter
// 6. AWS Bedrock // 6. AWS Bedrock
// 7. Azure // 7. Azure
// 8. Google Cloud VertexAI
// Anthropic configuration // Anthropic configuration
if key := viper.GetString("providers.anthropic.apiKey"); strings.TrimSpace(key) != "" { if key := viper.GetString("providers.anthropic.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.primary.model", models.Claude37Sonnet) viper.SetDefault("agents.primary.model", models.Claude4Sonnet)
viper.SetDefault("agents.task.model", models.Claude37Sonnet) viper.SetDefault("agents.task.model", models.Claude4Sonnet)
viper.SetDefault("agents.title.model", models.Claude37Sonnet) viper.SetDefault("agents.title.model", models.Claude4Sonnet)
return return
} }
@@ -299,6 +307,15 @@ func setProviderDefaults() {
viper.SetDefault("agents.title.model", models.AzureGPT41Mini) viper.SetDefault("agents.title.model", models.AzureGPT41Mini)
return 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. // hasAWSCredentials checks if AWS credentials are available in the environment.
@@ -327,6 +344,19 @@ func hasAWSCredentials() bool {
return false 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. // readConfig handles the result of reading a configuration file.
func readConfig(err error) error { func readConfig(err error) error {
if err == nil { if err == nil {
@@ -549,6 +579,10 @@ func getProviderAPIKey(provider models.ModelProvider) string {
if hasAWSCredentials() { if hasAWSCredentials() {
return "aws-credentials-available" return "aws-credentials-available"
} }
case models.ProviderVertexAI:
if hasVertexAICredentials() {
return "vertex-ai-credentials-available"
}
} }
return "" return ""
} }
@@ -562,7 +596,7 @@ func setDefaultModelForAgent(agent AgentName) bool {
maxTokens = 80 maxTokens = 80
} }
cfg.Agents[agent] = Agent{ cfg.Agents[agent] = Agent{
Model: models.Claude37Sonnet, Model: models.Claude4Sonnet,
MaxTokens: maxTokens, MaxTokens: maxTokens,
} }
return true return true
@@ -669,6 +703,24 @@ func setDefaultModelForAgent(agent AgentName) bool {
return true 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 return false
} }
@@ -704,6 +756,52 @@ func GetUsername() (string, error) {
return currentUser.Username, nil return currentUser.Username, nil
} }
func updateCfgFile(updateCfg func(config *Config)) error {
if cfg == nil {
return fmt.Errorf("config not loaded")
}
// Get the config file path
configFile := viper.ConfigFileUsed()
var configData []byte
if configFile == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home directory: %w", err)
}
configFile = filepath.Join(homeDir, fmt.Sprintf(".%s.json", appName))
slog.Info("config file not found, creating new one", "path", configFile)
configData = []byte(`{}`)
} else {
// Read the existing config file
data, err := os.ReadFile(configFile)
if err != nil {
return fmt.Errorf("failed to read config file: %w", err)
}
configData = data
}
// Parse the JSON
var userCfg *Config
if err := json.Unmarshal(configData, &userCfg); err != nil {
return fmt.Errorf("failed to parse config file: %w", err)
}
updateCfg(userCfg)
// Write the updated config back to file
updatedData, err := json.MarshalIndent(userCfg, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
if err := os.WriteFile(configFile, updatedData, 0o644); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
return nil
}
func UpdateAgentModel(agentName AgentName, modelID models.ModelID) error { func UpdateAgentModel(agentName AgentName, modelID models.ModelID) error {
if cfg == nil { if cfg == nil {
panic("config not loaded") panic("config not loaded")
@@ -734,7 +832,12 @@ func UpdateAgentModel(agentName AgentName, modelID models.ModelID) error {
return fmt.Errorf("failed to update agent model: %w", err) return fmt.Errorf("failed to update agent model: %w", err)
} }
return nil return updateCfgFile(func(config *Config) {
if config.Agents == nil {
config.Agents = make(map[AgentName]Agent)
}
config.Agents[agentName] = newAgentCfg
})
} }
// UpdateTheme updates the theme in the configuration and writes it to the config file. // UpdateTheme updates the theme in the configuration and writes it to the config file.
@@ -746,52 +849,8 @@ func UpdateTheme(themeName string) error {
// Update the in-memory config // Update the in-memory config
cfg.TUI.Theme = themeName cfg.TUI.Theme = themeName
// Get the config file path // Update the file config
configFile := viper.ConfigFileUsed() return updateCfgFile(func(config *Config) {
var configData []byte config.TUI.Theme = themeName
if configFile == "" { })
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home directory: %w", err)
}
configFile = filepath.Join(homeDir, fmt.Sprintf(".%s.json", appName))
slog.Info("config file not found, creating new one", "path", configFile)
configData = []byte(`{}`)
} else {
// Read the existing config file
data, err := os.ReadFile(configFile)
if err != nil {
return fmt.Errorf("failed to read config file: %w", err)
}
configData = data
}
// Parse the JSON
var configMap map[string]any
if err := json.Unmarshal(configData, &configMap); err != nil {
return fmt.Errorf("failed to parse config file: %w", err)
}
// Update just the theme value
tuiConfig, ok := configMap["tui"].(map[string]any)
if !ok {
// TUI config doesn't exist yet, create it
configMap["tui"] = map[string]any{"theme": themeName}
} else {
// Update existing TUI config
tuiConfig["theme"] = themeName
configMap["tui"] = tuiConfig
}
// Write the updated config back to file
updatedData, err := json.MarshalIndent(configMap, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
if err := os.WriteFile(configFile, updatedData, 0o644); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
return nil
} }
+163
View File
@@ -0,0 +1,163 @@
package fileutil
import (
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/bmatcuk/doublestar/v4"
"github.com/sst/opencode/internal/status"
)
var (
rgPath string
fzfPath string
)
func Init() {
var err error
rgPath, err = exec.LookPath("rg")
if err != nil {
status.Warn("Ripgrep (rg) not found in $PATH. Some features might be limited or slower.")
rgPath = ""
}
fzfPath, err = exec.LookPath("fzf")
if err != nil {
status.Warn("FZF not found in $PATH. Some features might be limited or slower.")
fzfPath = ""
}
}
func GetRgCmd(globPattern string) *exec.Cmd {
if rgPath == "" {
return nil
}
rgArgs := []string{
"--files",
"-L",
"--null",
}
if globPattern != "" {
if !filepath.IsAbs(globPattern) && !strings.HasPrefix(globPattern, "/") {
globPattern = "/" + globPattern
}
rgArgs = append(rgArgs, "--glob", globPattern)
}
cmd := exec.Command(rgPath, rgArgs...)
cmd.Dir = "."
return cmd
}
func GetFzfCmd(query string) *exec.Cmd {
if fzfPath == "" {
return nil
}
fzfArgs := []string{
"--filter",
query,
"--read0",
"--print0",
}
cmd := exec.Command(fzfPath, fzfArgs...)
cmd.Dir = "."
return cmd
}
type FileInfo struct {
Path string
ModTime time.Time
}
func SkipHidden(path string) bool {
// Check for hidden files (starting with a dot)
base := filepath.Base(path)
if base != "." && strings.HasPrefix(base, ".") {
return true
}
commonIgnoredDirs := map[string]bool{
".opencode": true,
"node_modules": true,
"vendor": true,
"dist": true,
"build": true,
"target": true,
".git": true,
".idea": true,
".vscode": true,
"__pycache__": true,
"bin": true,
"obj": true,
"out": true,
"coverage": true,
"tmp": true,
"temp": true,
"logs": true,
"generated": true,
"bower_components": true,
"jspm_packages": true,
}
parts := strings.Split(path, string(os.PathSeparator))
for _, part := range parts {
if commonIgnoredDirs[part] {
return true
}
}
return false
}
func GlobWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) {
fsys := os.DirFS(searchPath)
relPattern := strings.TrimPrefix(pattern, "/")
var matches []FileInfo
err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
if d.IsDir() {
return nil
}
if SkipHidden(path) {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
absPath := path
if !strings.HasPrefix(absPath, searchPath) && searchPath != "." {
absPath = filepath.Join(searchPath, absPath)
} else if !strings.HasPrefix(absPath, "/") && searchPath == "." {
absPath = filepath.Join(searchPath, absPath) // Ensure relative paths are joined correctly
}
matches = append(matches, FileInfo{Path: absPath, ModTime: info.ModTime()})
if limit > 0 && len(matches) >= limit*2 {
return fs.SkipAll
}
return nil
})
if err != nil {
return nil, false, fmt.Errorf("glob walk error: %w", err)
}
sort.Slice(matches, func(i, j int) bool {
return matches[i].ModTime.After(matches[j].ModTime)
})
truncated := false
if limit > 0 && len(matches) > limit {
matches = matches[:limit]
truncated = true
}
results := make([]string, len(matches))
for i, m := range matches {
results[i] = m.Path
}
return results, truncated, nil
}
+46
View File
@@ -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)
}
}
+90
View File
@@ -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)
}
})
}
}
+1 -11
View File
@@ -4,12 +4,11 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"strings" "strings"
"sync" "sync"
"time" "time"
"log/slog"
"github.com/sst/opencode/internal/config" "github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/models" "github.com/sst/opencode/internal/llm/models"
"github.com/sst/opencode/internal/llm/prompt" "github.com/sst/opencode/internal/llm/prompt"
@@ -522,15 +521,6 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
assistantMsg.AddToolCall(*event.ToolCall) assistantMsg.AddToolCall(*event.ToolCall)
_, err := a.messages.Update(ctx, *assistantMsg) _, err := a.messages.Update(ctx, *assistantMsg)
return err return err
// TODO: see how to handle this
// case provider.EventToolUseDelta:
// tm := time.Unix(assistantMsg.UpdatedAt, 0)
// assistantMsg.AppendToolCallInput(event.ToolCall.ID, event.ToolCall.Input)
// if time.Since(tm) > 1000*time.Millisecond {
// err := a.messages.Update(ctx, *assistantMsg)
// assistantMsg.UpdatedAt = time.Now().Unix()
// return err
// }
case provider.EventToolUseStop: case provider.EventToolUseStop:
assistantMsg.FinishToolCall(event.ToolCall.ID) assistantMsg.FinishToolCall(event.ToolCall.ID)
_, err := a.messages.Update(ctx, *assistantMsg) _, err := a.messages.Update(ctx, *assistantMsg)
+43 -20
View File
@@ -21,29 +21,41 @@ func PrimaryAgentTools(
ctx := context.Background() ctx := context.Background()
mcpTools := GetMcpTools(ctx, permissions) mcpTools := GetMcpTools(ctx, permissions)
return append( // Create the list of tools
[]tools.BaseTool{ toolsList := []tools.BaseTool{
tools.NewBashTool(permissions), tools.NewBashTool(permissions),
tools.NewEditTool(lspClients, permissions, history), tools.NewEditTool(lspClients, permissions, history),
tools.NewFetchTool(permissions), tools.NewFetchTool(permissions),
tools.NewGlobTool(), tools.NewGlobTool(),
tools.NewGrepTool(), tools.NewGrepTool(),
tools.NewLsTool(), tools.NewLsTool(),
tools.NewViewTool(lspClients), tools.NewViewTool(lspClients),
tools.NewPatchTool(lspClients, permissions, history), tools.NewPatchTool(lspClients, permissions, history),
tools.NewWriteTool(lspClients, permissions, history), tools.NewWriteTool(lspClients, permissions, history),
tools.NewDiagnosticsTool(lspClients), tools.NewDiagnosticsTool(lspClients),
tools.NewDefinitionTool(lspClients), tools.NewDefinitionTool(lspClients),
tools.NewReferencesTool(lspClients), tools.NewReferencesTool(lspClients),
tools.NewDocSymbolsTool(lspClients), tools.NewDocSymbolsTool(lspClients),
tools.NewWorkspaceSymbolsTool(lspClients), tools.NewWorkspaceSymbolsTool(lspClients),
NewAgentTool(sessions, messages, lspClients), tools.NewCodeActionTool(lspClients),
}, mcpTools..., 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 { func TaskAgentTools(lspClients map[string]*lsp.Client) []tools.BaseTool {
return []tools.BaseTool{ // Create the list of tools
toolsList := []tools.BaseTool{
tools.NewGlobTool(), tools.NewGlobTool(),
tools.NewGrepTool(), tools.NewGrepTool(),
tools.NewLsTool(), tools.NewLsTool(),
@@ -53,4 +65,15 @@ func TaskAgentTools(lspClients map[string]*lsp.Client) []tools.BaseTool {
tools.NewDocSymbolsTool(lspClients), tools.NewDocSymbolsTool(lspClients),
tools.NewWorkspaceSymbolsTool(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
} }
+15
View File
@@ -9,6 +9,7 @@ const (
Claude37Sonnet ModelID = "claude-3.7-sonnet" Claude37Sonnet ModelID = "claude-3.7-sonnet"
Claude35Haiku ModelID = "claude-3.5-haiku" Claude35Haiku ModelID = "claude-3.5-haiku"
Claude3Opus ModelID = "claude-3-opus" Claude3Opus ModelID = "claude-3-opus"
Claude4Sonnet ModelID = "claude-4-sonnet"
) )
// https://docs.anthropic.com/en/docs/about-claude/models/all-models // https://docs.anthropic.com/en/docs/about-claude/models/all-models
@@ -53,6 +54,20 @@ var AnthropicModels = map[ModelID]Model{
CanReason: true, CanReason: true,
SupportsAttachments: true, SupportsAttachments: true,
}, },
Claude4Sonnet: {
ID: Claude4Sonnet,
Name: "Claude 4 Sonnet",
Provider: ProviderAnthropic,
APIModel: "claude-sonnet-4-20250514",
CostPer1MIn: 3.0,
CostPer1MInCached: 3.75,
CostPer1MOutCached: 0.30,
CostPer1MOut: 15.0,
ContextWindow: 200000,
DefaultMaxTokens: 50000,
CanReason: true,
SupportsAttachments: true,
},
Claude35Haiku: { Claude35Haiku: {
ID: Claude35Haiku, ID: Claude35Haiku,
Name: "Claude 3.5 Haiku", Name: "Claude 3.5 Haiku",
+25
View File
@@ -0,0 +1,25 @@
package models
const (
ProviderBedrock ModelProvider = "bedrock"
// Models
BedrockClaude37Sonnet ModelID = "bedrock.claude-3.7-sonnet"
)
var BedrockModels = map[ModelID]Model{
BedrockClaude37Sonnet: {
ID: BedrockClaude37Sonnet,
Name: "Bedrock: Claude 3.7 Sonnet",
Provider: ProviderBedrock,
APIModel: "anthropic.claude-3-7-sonnet-20250219-v1:0",
CostPer1MIn: 3.0,
CostPer1MInCached: 3.75,
CostPer1MOutCached: 0.30,
CostPer1MOut: 15.0,
ContextWindow: 200_000,
DefaultMaxTokens: 50_000,
CanReason: true,
SupportsAttachments: true,
},
}
+2
View File
@@ -41,6 +41,7 @@ var GroqModels = map[ModelID]Model{
CostPer1MInCached: 0, CostPer1MInCached: 0,
CostPer1MOutCached: 0, CostPer1MOutCached: 0,
CostPer1MOut: 0.34, CostPer1MOut: 0.34,
DefaultMaxTokens: 8192,
ContextWindow: 128_000, // 10M when? ContextWindow: 128_000, // 10M when?
SupportsAttachments: true, SupportsAttachments: true,
}, },
@@ -54,6 +55,7 @@ var GroqModels = map[ModelID]Model{
CostPer1MInCached: 0, CostPer1MInCached: 0,
CostPer1MOutCached: 0, CostPer1MOutCached: 0,
CostPer1MOut: 0.20, CostPer1MOut: 0.20,
DefaultMaxTokens: 8192,
ContextWindow: 128_000, ContextWindow: 128_000,
SupportsAttachments: true, SupportsAttachments: true,
}, },
+4 -48
View File
@@ -22,14 +22,7 @@ type Model struct {
SupportsAttachments bool `json:"supports_attachments"` SupportsAttachments bool `json:"supports_attachments"`
} }
// Model IDs
const ( // GEMINI
// Bedrock
BedrockClaude37Sonnet ModelID = "bedrock.claude-3.7-sonnet"
)
const ( const (
ProviderBedrock ModelProvider = "bedrock"
// ForTests // ForTests
ProviderMock ModelProvider = "__mock" ProviderMock ModelProvider = "__mock"
) )
@@ -43,56 +36,19 @@ var ProviderPopularity = map[ModelProvider]int{
ProviderOpenRouter: 5, ProviderOpenRouter: 5,
ProviderBedrock: 6, ProviderBedrock: 6,
ProviderAzure: 7, ProviderAzure: 7,
ProviderVertexAI: 8,
} }
var SupportedModels = map[ModelID]Model{ var SupportedModels = map[ModelID]Model{}
//
// // GEMINI
// GEMINI25: {
// ID: GEMINI25,
// Name: "Gemini 2.5 Pro",
// Provider: ProviderGemini,
// APIModel: "gemini-2.5-pro-exp-03-25",
// CostPer1MIn: 0,
// CostPer1MInCached: 0,
// CostPer1MOutCached: 0,
// CostPer1MOut: 0,
// },
//
// GRMINI20Flash: {
// ID: GRMINI20Flash,
// Name: "Gemini 2.0 Flash",
// Provider: ProviderGemini,
// APIModel: "gemini-2.0-flash",
// CostPer1MIn: 0.1,
// CostPer1MInCached: 0,
// CostPer1MOutCached: 0.025,
// CostPer1MOut: 0.4,
// },
//
// // Bedrock
BedrockClaude37Sonnet: {
ID: BedrockClaude37Sonnet,
Name: "Bedrock: Claude 3.7 Sonnet",
Provider: ProviderBedrock,
APIModel: "anthropic.claude-3-7-sonnet-20250219-v1:0",
CostPer1MIn: 3.0,
CostPer1MInCached: 3.75,
CostPer1MOutCached: 0.30,
CostPer1MOut: 15.0,
ContextWindow: 200_000,
DefaultMaxTokens: 50_000,
CanReason: true,
SupportsAttachments: true,
},
}
func init() { func init() {
maps.Copy(SupportedModels, AnthropicModels) maps.Copy(SupportedModels, AnthropicModels)
maps.Copy(SupportedModels, BedrockModels)
maps.Copy(SupportedModels, OpenAIModels) maps.Copy(SupportedModels, OpenAIModels)
maps.Copy(SupportedModels, GeminiModels) maps.Copy(SupportedModels, GeminiModels)
maps.Copy(SupportedModels, GroqModels) maps.Copy(SupportedModels, GroqModels)
maps.Copy(SupportedModels, AzureModels) maps.Copy(SupportedModels, AzureModels)
maps.Copy(SupportedModels, OpenRouterModels) maps.Copy(SupportedModels, OpenRouterModels)
maps.Copy(SupportedModels, XAIModels) maps.Copy(SupportedModels, XAIModels)
maps.Copy(SupportedModels, VertexAIGeminiModels)
} }
+15
View File
@@ -3,6 +3,7 @@ package models
const ( const (
ProviderOpenAI ModelProvider = "openai" ProviderOpenAI ModelProvider = "openai"
CodexMini ModelID = "codex-mini"
GPT41 ModelID = "gpt-4.1" GPT41 ModelID = "gpt-4.1"
GPT41Mini ModelID = "gpt-4.1-mini" GPT41Mini ModelID = "gpt-4.1-mini"
GPT41Nano ModelID = "gpt-4.1-nano" GPT41Nano ModelID = "gpt-4.1-nano"
@@ -18,6 +19,20 @@ const (
) )
var OpenAIModels = map[ModelID]Model{ var OpenAIModels = map[ModelID]Model{
CodexMini: {
ID: CodexMini,
Name: "Codex Mini",
Provider: ProviderOpenAI,
APIModel: "codex-mini-latest",
CostPer1MIn: 1.50,
CostPer1MInCached: 0.375,
CostPer1MOutCached: 0.0,
CostPer1MOut: 6.00,
ContextWindow: 200_000,
DefaultMaxTokens: 100_000,
CanReason: true,
SupportsAttachments: true,
},
GPT41: { GPT41: {
ID: GPT41, ID: GPT41,
Name: "GPT 4.1", Name: "GPT 4.1",
+38
View File
@@ -0,0 +1,38 @@
package models
const (
ProviderVertexAI ModelProvider = "vertexai"
// Models
VertexAIGemini25Flash ModelID = "vertexai.gemini-2.5-flash"
VertexAIGemini25 ModelID = "vertexai.gemini-2.5"
)
var VertexAIGeminiModels = map[ModelID]Model{
VertexAIGemini25Flash: {
ID: VertexAIGemini25Flash,
Name: "VertexAI: Gemini 2.5 Flash",
Provider: ProviderVertexAI,
APIModel: "gemini-2.5-flash-preview-04-17",
CostPer1MIn: GeminiModels[Gemini25Flash].CostPer1MIn,
CostPer1MInCached: GeminiModels[Gemini25Flash].CostPer1MInCached,
CostPer1MOut: GeminiModels[Gemini25Flash].CostPer1MOut,
CostPer1MOutCached: GeminiModels[Gemini25Flash].CostPer1MOutCached,
ContextWindow: GeminiModels[Gemini25Flash].ContextWindow,
DefaultMaxTokens: GeminiModels[Gemini25Flash].DefaultMaxTokens,
SupportsAttachments: true,
},
VertexAIGemini25: {
ID: VertexAIGemini25,
Name: "VertexAI: Gemini 2.5 Pro",
Provider: ProviderVertexAI,
APIModel: "gemini-2.5-pro-preview-03-25",
CostPer1MIn: GeminiModels[Gemini25].CostPer1MIn,
CostPer1MInCached: GeminiModels[Gemini25].CostPer1MInCached,
CostPer1MOut: GeminiModels[Gemini25].CostPer1MOut,
CostPer1MOutCached: GeminiModels[Gemini25].CostPer1MOutCached,
ContextWindow: GeminiModels[Gemini25].ContextWindow,
DefaultMaxTokens: GeminiModels[Gemini25].DefaultMaxTokens,
SupportsAttachments: true,
},
}
+6 -4
View File
@@ -224,15 +224,16 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message,
if err != nil { if err != nil {
slog.Error("Error in Anthropic API call", "error", err) slog.Error("Error in Anthropic API call", "error", err)
retry, after, retryErr := a.shouldRetry(attempts, err) retry, after, retryErr := a.shouldRetry(attempts, err)
duration := time.Duration(after) * time.Millisecond
if retryErr != nil { if retryErr != nil {
return nil, retryErr return nil, retryErr
} }
if retry { 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 { select {
case <-ctx.Done(): case <-ctx.Done():
return nil, ctx.Err() return nil, ctx.Err()
case <-time.After(time.Duration(after) * time.Millisecond): case <-time.After(duration):
continue 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 // If there is an error we are going to see if we can retry the call
retry, after, retryErr := a.shouldRetry(attempts, err) retry, after, retryErr := a.shouldRetry(attempts, err)
duration := time.Duration(after) * time.Millisecond
if retryErr != nil { if retryErr != nil {
eventChan <- ProviderEvent{Type: EventError, Error: retryErr} eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
close(eventChan) close(eventChan)
return return
} }
if retry { 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 { select {
case <-ctx.Done(): case <-ctx.Done():
// context cancelled // context cancelled
@@ -375,7 +377,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
} }
close(eventChan) close(eventChan)
return return
case <-time.After(time.Duration(after) * time.Millisecond): case <-time.After(duration):
continue continue
} }
} }
+18 -10
View File
@@ -176,13 +176,16 @@ func (g *geminiClient) send(ctx context.Context, messages []message.Message, too
history := geminiMessages[:len(geminiMessages)-1] // All but last message history := geminiMessages[:len(geminiMessages)-1] // All but last message
lastMsg := geminiMessages[len(geminiMessages)-1] 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), MaxOutputTokens: int32(g.providerOptions.maxTokens),
SystemInstruction: &genai.Content{ SystemInstruction: &genai.Content{
Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}}, 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 attempts := 0
for { 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 there is an error we are going to see if we can retry the call
if err != nil { if err != nil {
retry, after, retryErr := g.shouldRetry(attempts, err) retry, after, retryErr := g.shouldRetry(attempts, err)
duration := time.Duration(after) * time.Millisecond
if retryErr != nil { if retryErr != nil {
return nil, retryErr return nil, retryErr
} }
if retry { 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 { select {
case <-ctx.Done(): case <-ctx.Done():
return nil, ctx.Err() return nil, ctx.Err()
case <-time.After(time.Duration(after) * time.Millisecond): case <-time.After(duration):
continue 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 history := geminiMessages[:len(geminiMessages)-1] // All but last message
lastMsg := geminiMessages[len(geminiMessages)-1] 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), MaxOutputTokens: int32(g.providerOptions.maxTokens),
SystemInstruction: &genai.Content{ SystemInstruction: &genai.Content{
Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}}, 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 attempts := 0
eventChan := make(chan ProviderEvent) 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...) { for resp, err := range chat.SendMessageStream(ctx, lastMsgParts...) {
if err != nil { if err != nil {
retry, after, retryErr := g.shouldRetry(attempts, err) retry, after, retryErr := g.shouldRetry(attempts, err)
duration := time.Duration(after) * time.Millisecond
if retryErr != nil { if retryErr != nil {
eventChan <- ProviderEvent{Type: EventError, Error: retryErr} eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
return return
} }
if retry { 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 { select {
case <-ctx.Done(): case <-ctx.Done():
if ctx.Err() != nil { if ctx.Err() != nil {
@@ -305,7 +313,7 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
} }
return return
case <-time.After(time.Duration(after) * time.Millisecond): case <-time.After(duration):
break break
} }
} else { } else {
+10 -295
View File
@@ -2,21 +2,14 @@ package provider
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "log/slog"
"time"
"github.com/openai/openai-go" "github.com/openai/openai-go"
"github.com/openai/openai-go/option" "github.com/openai/openai-go/option"
"github.com/openai/openai-go/shared"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/models" "github.com/sst/opencode/internal/llm/models"
"github.com/sst/opencode/internal/llm/tools" "github.com/sst/opencode/internal/llm/tools"
"github.com/sst/opencode/internal/message" "github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/status"
"log/slog"
) )
type openaiOptions struct { type openaiOptions struct {
@@ -66,87 +59,21 @@ func newOpenAIClient(opts providerClientOptions) OpenAIClient {
} }
} }
func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessages []openai.ChatCompletionMessageParamUnion) { func (o *openaiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
// Add system message first if o.providerOptions.model.ID == models.OpenAIModels[models.CodexMini].ID || o.providerOptions.model.ID == models.OpenAIModels[models.O1Pro].ID {
openaiMessages = append(openaiMessages, openai.SystemMessage(o.providerOptions.systemMessage)) return o.sendResponseMessages(ctx, messages, tools)
for _, msg := range messages {
switch msg.Role {
case message.User:
var content []openai.ChatCompletionContentPartUnionParam
textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
for _, binaryContent := range msg.BinaryContent() {
imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(models.ProviderOpenAI)}
imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
}
openaiMessages = append(openaiMessages, openai.UserMessage(content))
case message.Assistant:
assistantMsg := openai.ChatCompletionAssistantMessageParam{
Role: "assistant",
}
if msg.Content().String() != "" {
assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
OfString: openai.String(msg.Content().String()),
}
}
if len(msg.ToolCalls()) > 0 {
assistantMsg.ToolCalls = make([]openai.ChatCompletionMessageToolCallParam, len(msg.ToolCalls()))
for i, call := range msg.ToolCalls() {
assistantMsg.ToolCalls[i] = openai.ChatCompletionMessageToolCallParam{
ID: call.ID,
Type: "function",
Function: openai.ChatCompletionMessageToolCallFunctionParam{
Name: call.Name,
Arguments: call.Input,
},
}
}
}
openaiMessages = append(openaiMessages, openai.ChatCompletionMessageParamUnion{
OfAssistant: &assistantMsg,
})
case message.Tool:
for _, result := range msg.ToolResults() {
openaiMessages = append(openaiMessages,
openai.ToolMessage(result.Content, result.ToolCallID),
)
}
}
} }
return o.sendChatcompletionMessage(ctx, messages, tools)
return
} }
func (o *openaiClient) convertTools(tools []tools.BaseTool) []openai.ChatCompletionToolParam { func (o *openaiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
openaiTools := make([]openai.ChatCompletionToolParam, len(tools)) if o.providerOptions.model.ID == models.OpenAIModels[models.CodexMini].ID || o.providerOptions.model.ID == models.OpenAIModels[models.O1Pro].ID {
return o.streamResponseMessages(ctx, messages, tools)
for i, tool := range tools {
info := tool.Info()
openaiTools[i] = openai.ChatCompletionToolParam{
Function: openai.FunctionDefinitionParam{
Name: info.Name,
Description: openai.String(info.Description),
Parameters: openai.FunctionParameters{
"type": "object",
"properties": info.Parameters,
"required": info.Required,
},
},
}
} }
return o.streamChatCompletionMessages(ctx, messages, tools)
return openaiTools
} }
func (o *openaiClient) finishReason(reason string) message.FinishReason { func (o *openaiClient) finishReason(reason string) message.FinishReason {
switch reason { switch reason {
case "stop": case "stop":
@@ -160,188 +87,6 @@ func (o *openaiClient) finishReason(reason string) message.FinishReason {
} }
} }
func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessageParamUnion, tools []openai.ChatCompletionToolParam) openai.ChatCompletionNewParams {
params := openai.ChatCompletionNewParams{
Model: openai.ChatModel(o.providerOptions.model.APIModel),
Messages: messages,
Tools: tools,
}
if o.providerOptions.model.CanReason == true {
params.MaxCompletionTokens = openai.Int(o.providerOptions.maxTokens)
switch o.options.reasoningEffort {
case "low":
params.ReasoningEffort = shared.ReasoningEffortLow
case "medium":
params.ReasoningEffort = shared.ReasoningEffortMedium
case "high":
params.ReasoningEffort = shared.ReasoningEffortHigh
default:
params.ReasoningEffort = shared.ReasoningEffortMedium
}
} else {
params.MaxTokens = openai.Int(o.providerOptions.maxTokens)
}
if o.providerOptions.model.Provider == models.ProviderOpenRouter {
params.WithExtraFields(map[string]any{
"provider": map[string]any{
"require_parameters": true,
},
})
}
return params
}
func (o *openaiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools))
cfg := config.Get()
if cfg.Debug {
jsonData, _ := json.Marshal(params)
slog.Debug("Prepared messages", "messages", string(jsonData))
}
attempts := 0
for {
attempts++
openaiResponse, err := o.client.Chat.Completions.New(
ctx,
params,
)
// 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)
if retryErr != nil {
return nil, retryErr
}
if retry {
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(time.Duration(after) * time.Millisecond):
continue
}
}
return nil, retryErr
}
content := ""
if openaiResponse.Choices[0].Message.Content != "" {
content = openaiResponse.Choices[0].Message.Content
}
toolCalls := o.toolCalls(*openaiResponse)
finishReason := o.finishReason(string(openaiResponse.Choices[0].FinishReason))
if len(toolCalls) > 0 {
finishReason = message.FinishReasonToolUse
}
return &ProviderResponse{
Content: content,
ToolCalls: toolCalls,
Usage: o.usage(*openaiResponse),
FinishReason: finishReason,
}, nil
}
}
func (o *openaiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools))
params.StreamOptions = openai.ChatCompletionStreamOptionsParam{
IncludeUsage: openai.Bool(true),
}
cfg := config.Get()
if cfg.Debug {
jsonData, _ := json.Marshal(params)
slog.Debug("Prepared messages", "messages", string(jsonData))
}
attempts := 0
eventChan := make(chan ProviderEvent)
go func() {
for {
attempts++
openaiStream := o.client.Chat.Completions.NewStreaming(
ctx,
params,
)
acc := openai.ChatCompletionAccumulator{}
currentContent := ""
toolCalls := make([]message.ToolCall, 0)
for openaiStream.Next() {
chunk := openaiStream.Current()
acc.AddChunk(chunk)
for _, choice := range chunk.Choices {
if choice.Delta.Content != "" {
eventChan <- ProviderEvent{
Type: EventContentDelta,
Content: choice.Delta.Content,
}
currentContent += choice.Delta.Content
}
}
}
err := openaiStream.Err()
if err == nil || errors.Is(err, io.EOF) {
// Stream completed successfully
finishReason := o.finishReason(string(acc.ChatCompletion.Choices[0].FinishReason))
if len(acc.ChatCompletion.Choices[0].Message.ToolCalls) > 0 {
toolCalls = append(toolCalls, o.toolCalls(acc.ChatCompletion)...)
}
if len(toolCalls) > 0 {
finishReason = message.FinishReasonToolUse
}
eventChan <- ProviderEvent{
Type: EventComplete,
Response: &ProviderResponse{
Content: currentContent,
ToolCalls: toolCalls,
Usage: o.usage(acc.ChatCompletion),
FinishReason: finishReason,
},
}
close(eventChan)
return
}
// If there is an error we are going to see if we can retry the call
retry, after, retryErr := o.shouldRetry(attempts, err)
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))
select {
case <-ctx.Done():
// context cancelled
if ctx.Err() == nil {
eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
}
close(eventChan)
return
case <-time.After(time.Duration(after) * time.Millisecond):
continue
}
}
eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
close(eventChan)
return
}
}()
return eventChan
}
func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error) { func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error) {
var apierr *openai.Error var apierr *openai.Error
@@ -371,36 +116,6 @@ func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error)
return true, int64(retryMs), nil return true, int64(retryMs), nil
} }
func (o *openaiClient) toolCalls(completion openai.ChatCompletion) []message.ToolCall {
var toolCalls []message.ToolCall
if len(completion.Choices) > 0 && len(completion.Choices[0].Message.ToolCalls) > 0 {
for _, call := range completion.Choices[0].Message.ToolCalls {
toolCall := message.ToolCall{
ID: call.ID,
Name: call.Function.Name,
Input: call.Function.Arguments,
Type: "function",
Finished: true,
}
toolCalls = append(toolCalls, toolCall)
}
}
return toolCalls
}
func (o *openaiClient) usage(completion openai.ChatCompletion) TokenUsage {
cachedTokens := completion.Usage.PromptTokensDetails.CachedTokens
inputTokens := completion.Usage.PromptTokens - cachedTokens
return TokenUsage{
InputTokens: inputTokens,
OutputTokens: completion.Usage.CompletionTokens,
CacheCreationTokens: 0, // OpenAI doesn't provide this directly
CacheReadTokens: cachedTokens,
}
}
func WithOpenAIBaseURL(baseURL string) OpenAIOption { func WithOpenAIBaseURL(baseURL string) OpenAIOption {
return func(options *openaiOptions) { return func(options *openaiOptions) {
+317
View File
@@ -0,0 +1,317 @@
package provider
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"time"
"github.com/openai/openai-go"
"github.com/openai/openai-go/shared"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/models"
"github.com/sst/opencode/internal/llm/tools"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/status"
)
func (o *openaiClient) convertMessagesToChatCompletionMessages(messages []message.Message) (openaiMessages []openai.ChatCompletionMessageParamUnion) {
// Add system message first
openaiMessages = append(openaiMessages, openai.SystemMessage(o.providerOptions.systemMessage))
for _, msg := range messages {
switch msg.Role {
case message.User:
var content []openai.ChatCompletionContentPartUnionParam
textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
for _, binaryContent := range msg.BinaryContent() {
imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(models.ProviderOpenAI)}
imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
}
openaiMessages = append(openaiMessages, openai.UserMessage(content))
case message.Assistant:
assistantMsg := openai.ChatCompletionAssistantMessageParam{
Role: "assistant",
}
if msg.Content().String() != "" {
assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
OfString: openai.String(msg.Content().String()),
}
}
if len(msg.ToolCalls()) > 0 {
assistantMsg.ToolCalls = make([]openai.ChatCompletionMessageToolCallParam, len(msg.ToolCalls()))
for i, call := range msg.ToolCalls() {
assistantMsg.ToolCalls[i] = openai.ChatCompletionMessageToolCallParam{
ID: call.ID,
Type: "function",
Function: openai.ChatCompletionMessageToolCallFunctionParam{
Name: call.Name,
Arguments: call.Input,
},
}
}
}
openaiMessages = append(openaiMessages, openai.ChatCompletionMessageParamUnion{
OfAssistant: &assistantMsg,
})
case message.Tool:
for _, result := range msg.ToolResults() {
openaiMessages = append(openaiMessages,
openai.ToolMessage(result.Content, result.ToolCallID),
)
}
}
}
return
}
func (o *openaiClient) convertToChatCompletionTools(tools []tools.BaseTool) []openai.ChatCompletionToolParam {
openaiTools := make([]openai.ChatCompletionToolParam, len(tools))
for i, tool := range tools {
info := tool.Info()
openaiTools[i] = openai.ChatCompletionToolParam{
Function: openai.FunctionDefinitionParam{
Name: info.Name,
Description: openai.String(info.Description),
Parameters: openai.FunctionParameters{
"type": "object",
"properties": info.Parameters,
"required": info.Required,
},
},
}
}
return openaiTools
}
func (o *openaiClient) preparedChatCompletionParams(messages []openai.ChatCompletionMessageParamUnion, tools []openai.ChatCompletionToolParam) openai.ChatCompletionNewParams {
params := openai.ChatCompletionNewParams{
Model: openai.ChatModel(o.providerOptions.model.APIModel),
Messages: messages,
Tools: tools,
}
if o.providerOptions.model.CanReason == true {
params.MaxCompletionTokens = openai.Int(o.providerOptions.maxTokens)
switch o.options.reasoningEffort {
case "low":
params.ReasoningEffort = shared.ReasoningEffortLow
case "medium":
params.ReasoningEffort = shared.ReasoningEffortMedium
case "high":
params.ReasoningEffort = shared.ReasoningEffortHigh
default:
params.ReasoningEffort = shared.ReasoningEffortMedium
}
} else {
params.MaxTokens = openai.Int(o.providerOptions.maxTokens)
}
if o.providerOptions.model.Provider == models.ProviderOpenRouter {
params.WithExtraFields(map[string]any{
"provider": map[string]any{
"require_parameters": true,
},
})
}
return params
}
func (o *openaiClient) sendChatcompletionMessage(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
params := o.preparedChatCompletionParams(o.convertMessagesToChatCompletionMessages(messages), o.convertToChatCompletionTools(tools))
cfg := config.Get()
if cfg.Debug {
jsonData, _ := json.Marshal(params)
slog.Debug("Prepared messages", "messages", string(jsonData))
}
attempts := 0
for {
attempts++
openaiResponse, err := o.client.Chat.Completions.New(
ctx,
params,
)
// 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.WithDuration(duration))
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(duration):
continue
}
}
return nil, retryErr
}
content := ""
if openaiResponse.Choices[0].Message.Content != "" {
content = openaiResponse.Choices[0].Message.Content
}
toolCalls := o.chatCompletionToolCalls(*openaiResponse)
finishReason := o.finishReason(string(openaiResponse.Choices[0].FinishReason))
if len(toolCalls) > 0 {
finishReason = message.FinishReasonToolUse
}
return &ProviderResponse{
Content: content,
ToolCalls: toolCalls,
Usage: o.usage(*openaiResponse),
FinishReason: finishReason,
}, nil
}
}
func (o *openaiClient) streamChatCompletionMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
params := o.preparedChatCompletionParams(o.convertMessagesToChatCompletionMessages(messages), o.convertToChatCompletionTools(tools))
params.StreamOptions = openai.ChatCompletionStreamOptionsParam{
IncludeUsage: openai.Bool(true),
}
cfg := config.Get()
if cfg.Debug {
jsonData, _ := json.Marshal(params)
slog.Debug("Prepared messages", "messages", string(jsonData))
}
attempts := 0
eventChan := make(chan ProviderEvent)
go func() {
for {
attempts++
openaiStream := o.client.Chat.Completions.NewStreaming(
ctx,
params,
)
acc := openai.ChatCompletionAccumulator{}
currentContent := ""
toolCalls := make([]message.ToolCall, 0)
for openaiStream.Next() {
chunk := openaiStream.Current()
acc.AddChunk(chunk)
for _, choice := range chunk.Choices {
if choice.Delta.Content != "" {
eventChan <- ProviderEvent{
Type: EventContentDelta,
Content: choice.Delta.Content,
}
currentContent += choice.Delta.Content
}
}
}
err := openaiStream.Err()
if err == nil || errors.Is(err, io.EOF) {
// Stream completed successfully
finishReason := o.finishReason(string(acc.ChatCompletion.Choices[0].FinishReason))
if len(acc.ChatCompletion.Choices[0].Message.ToolCalls) > 0 {
toolCalls = append(toolCalls, o.chatCompletionToolCalls(acc.ChatCompletion)...)
}
if len(toolCalls) > 0 {
finishReason = message.FinishReasonToolUse
}
eventChan <- ProviderEvent{
Type: EventComplete,
Response: &ProviderResponse{
Content: currentContent,
ToolCalls: toolCalls,
Usage: o.usage(acc.ChatCompletion),
FinishReason: finishReason,
},
}
close(eventChan)
return
}
// 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.WithDuration(duration))
select {
case <-ctx.Done():
// context cancelled
if ctx.Err() == nil {
eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
}
close(eventChan)
return
case <-time.After(duration):
continue
}
}
eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
close(eventChan)
return
}
}()
return eventChan
}
func (o *openaiClient) chatCompletionToolCalls(completion openai.ChatCompletion) []message.ToolCall {
var toolCalls []message.ToolCall
if len(completion.Choices) > 0 && len(completion.Choices[0].Message.ToolCalls) > 0 {
for _, call := range completion.Choices[0].Message.ToolCalls {
toolCall := message.ToolCall{
ID: call.ID,
Name: call.Function.Name,
Input: call.Function.Arguments,
Type: "function",
Finished: true,
}
toolCalls = append(toolCalls, toolCall)
}
}
return toolCalls
}
func (o *openaiClient) usage(completion openai.ChatCompletion) TokenUsage {
cachedTokens := completion.Usage.PromptTokensDetails.CachedTokens
inputTokens := completion.Usage.PromptTokens - cachedTokens
return TokenUsage{
InputTokens: inputTokens,
OutputTokens: completion.Usage.CompletionTokens,
CacheCreationTokens: 0, // OpenAI doesn't provide this directly
CacheReadTokens: cachedTokens,
}
}
+393
View File
@@ -0,0 +1,393 @@
package provider
import (
"github.com/openai/openai-go"
"github.com/openai/openai-go/responses"
"github.com/sst/opencode/internal/llm/models"
"github.com/sst/opencode/internal/llm/tools"
"github.com/sst/opencode/internal/message"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"time"
"log/slog"
"github.com/openai/openai-go/shared"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/status"
)
func (o *openaiClient) convertMessagesToResponseParams(messages []message.Message) responses.ResponseInputParam {
inputItems := responses.ResponseInputParam{}
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
OfMessage: &responses.EasyInputMessageParam{
Content: responses.EasyInputMessageContentUnionParam{OfString: openai.String(o.providerOptions.systemMessage)},
Role: responses.EasyInputMessageRoleSystem,
},
})
for _, msg := range messages {
switch msg.Role {
case message.User:
inputItemContentList := responses.ResponseInputMessageContentListParam{
responses.ResponseInputContentUnionParam{
OfInputText: &responses.ResponseInputTextParam{
Text: msg.Content().String(),
},
},
}
for _, binaryContent := range msg.BinaryContent() {
inputItemContentList = append(inputItemContentList, responses.ResponseInputContentUnionParam{
OfInputImage: &responses.ResponseInputImageParam{
ImageURL: openai.String(binaryContent.String(models.ProviderOpenAI)),
},
})
}
userMsg := responses.ResponseInputItemUnionParam{
OfInputMessage: &responses.ResponseInputItemMessageParam{
Content: inputItemContentList,
Role: string(responses.ResponseInputMessageItemRoleUser),
},
}
inputItems = append(inputItems, userMsg)
case message.Assistant:
if msg.Content().String() != "" {
assistantMsg := responses.ResponseInputItemUnionParam{
OfOutputMessage: &responses.ResponseOutputMessageParam{
Content: []responses.ResponseOutputMessageContentUnionParam{{
OfOutputText: &responses.ResponseOutputTextParam{
Text: msg.Content().String(),
},
}},
},
}
inputItems = append(inputItems, assistantMsg)
}
if len(msg.ToolCalls()) > 0 {
for _, call := range msg.ToolCalls() {
toolMsg := responses.ResponseInputItemUnionParam{
OfFunctionCall: &responses.ResponseFunctionToolCallParam{
CallID: call.ID,
Name: call.Name,
Arguments: call.Input,
},
}
inputItems = append(inputItems, toolMsg)
}
}
case message.Tool:
for _, result := range msg.ToolResults() {
toolMsg := responses.ResponseInputItemUnionParam{
OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{
Output: result.Content,
CallID: result.ToolCallID,
},
}
inputItems = append(inputItems, toolMsg)
}
}
}
return inputItems
}
func (o *openaiClient) convertToResponseTools(tools []tools.BaseTool) []responses.ToolUnionParam {
outputTools := make([]responses.ToolUnionParam, len(tools))
for i, tool := range tools {
info := tool.Info()
outputTools[i] = responses.ToolUnionParam{
OfFunction: &responses.FunctionToolParam{
Name: info.Name,
Description: openai.String(info.Description),
Parameters: map[string]any{
"type": "object",
"properties": info.Parameters,
"required": info.Required,
},
},
}
}
return outputTools
}
func (o *openaiClient) preparedResponseParams(input responses.ResponseInputParam, tools []responses.ToolUnionParam) responses.ResponseNewParams {
params := responses.ResponseNewParams{
Model: shared.ResponsesModel(o.providerOptions.model.APIModel),
Input: responses.ResponseNewParamsInputUnion{OfInputItemList: input},
Tools: tools,
}
params.MaxOutputTokens = openai.Int(o.providerOptions.maxTokens)
if o.providerOptions.model.CanReason == true {
switch o.options.reasoningEffort {
case "low":
params.Reasoning.Effort = shared.ReasoningEffortLow
case "medium":
params.Reasoning.Effort = shared.ReasoningEffortMedium
case "high":
params.Reasoning.Effort = shared.ReasoningEffortHigh
default:
params.Reasoning.Effort = shared.ReasoningEffortMedium
}
}
if o.providerOptions.model.Provider == models.ProviderOpenRouter {
params.WithExtraFields(map[string]any{
"provider": map[string]any{
"require_parameters": true,
},
})
}
return params
}
func (o *openaiClient) sendResponseMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
params := o.preparedResponseParams(o.convertMessagesToResponseParams(messages), o.convertToResponseTools(tools))
cfg := config.Get()
if cfg.Debug {
jsonData, _ := json.Marshal(params)
slog.Debug("Prepared messages", "messages", string(jsonData))
}
attempts := 0
for {
attempts++
openaiResponse, err := o.client.Responses.New(
ctx,
params,
)
// 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.WithDuration(duration))
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(duration):
continue
}
}
return nil, retryErr
}
content := ""
if openaiResponse.OutputText() != "" {
content = openaiResponse.OutputText()
}
toolCalls := o.responseToolCalls(*openaiResponse)
finishReason := o.finishReason("stop")
if len(toolCalls) > 0 {
finishReason = message.FinishReasonToolUse
}
return &ProviderResponse{
Content: content,
ToolCalls: toolCalls,
Usage: o.responseUsage(*openaiResponse),
FinishReason: finishReason,
}, nil
}
}
func (o *openaiClient) streamResponseMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
eventChan := make(chan ProviderEvent)
params := o.preparedResponseParams(o.convertMessagesToResponseParams(messages), o.convertToResponseTools(tools))
cfg := config.Get()
if cfg.Debug {
jsonData, _ := json.Marshal(params)
slog.Debug("Prepared messages", "messages", string(jsonData))
}
attempts := 0
go func() {
for {
attempts++
stream := o.client.Responses.NewStreaming(ctx, params)
outputText := ""
currentToolCallID := ""
for stream.Next() {
event := stream.Current()
switch event := event.AsAny().(type) {
case responses.ResponseCompletedEvent:
toolCalls := o.responseToolCalls(event.Response)
finishReason := o.finishReason("stop")
if len(toolCalls) > 0 {
finishReason = message.FinishReasonToolUse
}
eventChan <- ProviderEvent{
Type: EventComplete,
Response: &ProviderResponse{
Content: outputText,
ToolCalls: toolCalls,
Usage: o.responseUsage(event.Response),
FinishReason: finishReason,
},
}
close(eventChan)
return
case responses.ResponseTextDeltaEvent:
outputText += event.Delta
eventChan <- ProviderEvent{
Type: EventContentDelta,
Content: event.Delta,
}
case responses.ResponseTextDoneEvent:
eventChan <- ProviderEvent{
Type: EventContentStop,
Content: outputText,
}
close(eventChan)
return
case responses.ResponseOutputItemAddedEvent:
if event.Item.Type == "function_call" {
currentToolCallID = event.Item.ID
eventChan <- ProviderEvent{
Type: EventToolUseStart,
ToolCall: &message.ToolCall{
ID: event.Item.ID,
Name: event.Item.Name,
Finished: false,
},
}
}
case responses.ResponseFunctionCallArgumentsDeltaEvent:
if event.ItemID == currentToolCallID {
eventChan <- ProviderEvent{
Type: EventToolUseDelta,
ToolCall: &message.ToolCall{
ID: currentToolCallID,
Finished: false,
Input: event.Delta,
},
}
}
case responses.ResponseFunctionCallArgumentsDoneEvent:
if event.ItemID == currentToolCallID {
eventChan <- ProviderEvent{
Type: EventToolUseStop,
ToolCall: &message.ToolCall{
ID: currentToolCallID,
Input: event.Arguments,
},
}
currentToolCallID = ""
}
case responses.ResponseOutputItemDoneEvent:
if event.Item.Type == "function_call" {
eventChan <- ProviderEvent{
Type: EventToolUseStop,
ToolCall: &message.ToolCall{
ID: event.Item.ID,
Name: event.Item.Name,
Input: event.Item.Arguments,
Finished: true,
},
}
currentToolCallID = ""
}
}
}
err := stream.Err()
if err == nil || errors.Is(err, io.EOF) {
close(eventChan)
return
}
// 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.WithDuration(duration))
select {
case <-ctx.Done():
// context cancelled
if ctx.Err() == nil {
eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
}
close(eventChan)
return
case <-time.After(duration):
continue
}
}
eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
close(eventChan)
return
}
}()
return eventChan
}
func (o *openaiClient) responseToolCalls(response responses.Response) []message.ToolCall {
var toolCalls []message.ToolCall
for _, output := range response.Output {
if output.Type == "function_call" {
call := output.AsFunctionCall()
toolCall := message.ToolCall{
ID: call.ID,
Name: call.Name,
Input: call.Arguments,
Type: "function",
Finished: true,
}
toolCalls = append(toolCalls, toolCall)
}
}
return toolCalls
}
func (o *openaiClient) responseUsage(response responses.Response) TokenUsage {
cachedTokens := response.Usage.InputTokensDetails.CachedTokens
inputTokens := response.Usage.InputTokens - cachedTokens
return TokenUsage{
InputTokens: inputTokens,
OutputTokens: response.Usage.OutputTokens,
CacheCreationTokens: 0, // OpenAI doesn't provide this directly
CacheReadTokens: cachedTokens,
}
}
+6 -1
View File
@@ -3,11 +3,11 @@ package provider
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"github.com/sst/opencode/internal/llm/models" "github.com/sst/opencode/internal/llm/models"
"github.com/sst/opencode/internal/llm/tools" "github.com/sst/opencode/internal/llm/tools"
"github.com/sst/opencode/internal/message" "github.com/sst/opencode/internal/message"
"log/slog"
) )
type EventType string type EventType string
@@ -123,6 +123,11 @@ func NewProvider(providerName models.ModelProvider, opts ...ProviderClientOption
options: clientOptions, options: clientOptions,
client: newAzureClient(clientOptions), client: newAzureClient(clientOptions),
}, nil }, nil
case models.ProviderVertexAI:
return &baseProvider[VertexAIClient]{
options: clientOptions,
client: newVertexAIClient(clientOptions),
}, nil
case models.ProviderOpenRouter: case models.ProviderOpenRouter:
clientOptions.openaiOptions = append(clientOptions.openaiOptions, clientOptions.openaiOptions = append(clientOptions.openaiOptions,
WithOpenAIBaseURL("https://openrouter.ai/api/v1"), WithOpenAIBaseURL("https://openrouter.ai/api/v1"),
+34
View File
@@ -0,0 +1,34 @@
package provider
import (
"context"
"log/slog"
"os"
"google.golang.org/genai"
)
type VertexAIClient ProviderClient
func newVertexAIClient(opts providerClientOptions) VertexAIClient {
geminiOpts := geminiOptions{}
for _, o := range opts.geminiOptions {
o(&geminiOpts)
}
client, err := genai.NewClient(context.Background(), &genai.ClientConfig{
Project: os.Getenv("VERTEXAI_PROJECT"),
Location: os.Getenv("VERTEXAI_LOCATION"),
Backend: genai.BackendVertexAI,
})
if err != nil {
slog.Error("Failed to create VertexAI client", "error", err)
return nil
}
return &geminiClient{
providerOptions: opts,
options: geminiOpts,
client: client,
}
}
+191
View File
@@ -0,0 +1,191 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
)
type BatchToolCall struct {
Name string `json:"name"`
Input json.RawMessage `json:"input"`
}
type BatchParams struct {
Calls []BatchToolCall `json:"calls"`
}
type BatchToolResult struct {
ToolName string `json:"tool_name"`
ToolInput json.RawMessage `json:"tool_input"`
Result json.RawMessage `json:"result"`
Error string `json:"error,omitempty"`
// Added for better formatting and separation between results
Separator string `json:"separator,omitempty"`
}
type BatchResult struct {
Results []BatchToolResult `json:"results"`
}
type batchTool struct {
tools map[string]BaseTool
}
const (
BatchToolName = "batch"
BatchToolDescription = `Executes multiple tool calls in parallel and returns their results.
WHEN TO USE THIS TOOL:
- Use when you need to run multiple independent tool calls at once
- Helpful for improving performance by parallelizing operations
- Great for gathering information from multiple sources simultaneously
HOW TO USE:
- Provide an array of tool calls, each with a name and input
- Each tool call will be executed in parallel
- Results are returned in the same order as the input calls
FEATURES:
- Runs tool calls concurrently for better performance
- Returns both results and errors for each call
- Maintains the order of results to match input calls
LIMITATIONS:
- All tools must be available in the current context
- Complex error handling may be required for some use cases
- Not suitable for tool calls that depend on each other's results
TIPS:
- Use for independent operations like multiple file reads or searches
- Great for batch operations like searching multiple directories
- Combine with other tools for more complex workflows`
)
func NewBatchTool(tools map[string]BaseTool) BaseTool {
return &batchTool{
tools: tools,
}
}
func (b *batchTool) Info() ToolInfo {
return ToolInfo{
Name: BatchToolName,
Description: BatchToolDescription,
Parameters: map[string]any{
"calls": map[string]any{
"type": "array",
"description": "Array of tool calls to execute in parallel",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{
"type": "string",
"description": "Name of the tool to call",
},
"input": map[string]any{
"type": "object",
"description": "Input parameters for the tool",
},
},
"required": []string{"name", "input"},
},
},
},
Required: []string{"calls"},
}
}
func (b *batchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
var params BatchParams
if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
}
if len(params.Calls) == 0 {
return NewTextErrorResponse("no tool calls provided"), nil
}
var wg sync.WaitGroup
results := make([]BatchToolResult, len(params.Calls))
for i, toolCall := range params.Calls {
wg.Add(1)
go func(index int, tc BatchToolCall) {
defer wg.Done()
// Create separator for better visual distinction between results
separator := ""
if index > 0 {
separator = fmt.Sprintf("\n%s\n", strings.Repeat("=", 80))
}
result := BatchToolResult{
ToolName: tc.Name,
ToolInput: tc.Input,
Separator: separator,
}
tool, ok := b.tools[tc.Name]
if !ok {
result.Error = fmt.Sprintf("tool not found: %s", tc.Name)
results[index] = result
return
}
// Create a proper ToolCall object
callObj := ToolCall{
ID: fmt.Sprintf("batch-%d", index),
Name: tc.Name,
Input: string(tc.Input),
}
response, err := tool.Run(ctx, callObj)
if err != nil {
result.Error = fmt.Sprintf("error executing tool %s: %s", tc.Name, err)
results[index] = result
return
}
// Standardize metadata format if present
if response.Metadata != "" {
var metadata map[string]interface{}
if err := json.Unmarshal([]byte(response.Metadata), &metadata); err == nil {
// Add tool name to metadata for better context
metadata["tool"] = tc.Name
// Re-marshal with consistent formatting
if metadataBytes, err := json.MarshalIndent(metadata, "", " "); err == nil {
response.Metadata = string(metadataBytes)
}
}
}
// Convert the response to JSON
responseJSON, err := json.Marshal(response)
if err != nil {
result.Error = fmt.Sprintf("error marshaling response: %s", err)
results[index] = result
return
}
result.Result = responseJSON
results[index] = result
}(i, toolCall)
}
wg.Wait()
batchResult := BatchResult{
Results: results,
}
resultJSON, err := json.Marshal(batchResult)
if err != nil {
return NewTextErrorResponse(fmt.Sprintf("error marshaling batch result: %s", err)), nil
}
return NewTextResponse(string(resultJSON)), nil
}
+224
View File
@@ -0,0 +1,224 @@
package tools
import (
"context"
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
)
// MockTool is a simple tool implementation for testing
type MockTool struct {
name string
description string
response ToolResponse
err error
}
func (m *MockTool) Info() ToolInfo {
return ToolInfo{
Name: m.name,
Description: m.description,
Parameters: map[string]any{},
Required: []string{},
}
}
func (m *MockTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
return m.response, m.err
}
func TestBatchTool(t *testing.T) {
t.Parallel()
t.Run("successful batch execution", func(t *testing.T) {
t.Parallel()
// Create mock tools
mockTools := map[string]BaseTool{
"tool1": &MockTool{
name: "tool1",
description: "Mock Tool 1",
response: NewTextResponse("Tool 1 Response"),
err: nil,
},
"tool2": &MockTool{
name: "tool2",
description: "Mock Tool 2",
response: NewTextResponse("Tool 2 Response"),
err: nil,
},
}
// Create batch tool
batchTool := NewBatchTool(mockTools)
// Create batch call
input := `{
"calls": [
{
"name": "tool1",
"input": {}
},
{
"name": "tool2",
"input": {}
}
]
}`
call := ToolCall{
ID: "test-batch",
Name: "batch",
Input: input,
}
// Execute batch
response, err := batchTool.Run(context.Background(), call)
// Verify results
assert.NoError(t, err)
assert.Equal(t, ToolResponseTypeText, response.Type)
assert.False(t, response.IsError)
// Parse the response
var batchResult BatchResult
err = json.Unmarshal([]byte(response.Content), &batchResult)
assert.NoError(t, err)
// Verify batch results
assert.Len(t, batchResult.Results, 2)
assert.Empty(t, batchResult.Results[0].Error)
assert.Empty(t, batchResult.Results[1].Error)
assert.Empty(t, batchResult.Results[0].Separator)
assert.NotEmpty(t, batchResult.Results[1].Separator)
// Verify individual results
var result1 ToolResponse
err = json.Unmarshal(batchResult.Results[0].Result, &result1)
assert.NoError(t, err)
assert.Equal(t, "Tool 1 Response", result1.Content)
var result2 ToolResponse
err = json.Unmarshal(batchResult.Results[1].Result, &result2)
assert.NoError(t, err)
assert.Equal(t, "Tool 2 Response", result2.Content)
})
t.Run("tool not found", func(t *testing.T) {
t.Parallel()
// Create mock tools
mockTools := map[string]BaseTool{
"tool1": &MockTool{
name: "tool1",
description: "Mock Tool 1",
response: NewTextResponse("Tool 1 Response"),
err: nil,
},
}
// Create batch tool
batchTool := NewBatchTool(mockTools)
// Create batch call with non-existent tool
input := `{
"calls": [
{
"name": "tool1",
"input": {}
},
{
"name": "nonexistent",
"input": {}
}
]
}`
call := ToolCall{
ID: "test-batch",
Name: "batch",
Input: input,
}
// Execute batch
response, err := batchTool.Run(context.Background(), call)
// Verify results
assert.NoError(t, err)
assert.Equal(t, ToolResponseTypeText, response.Type)
assert.False(t, response.IsError)
// Parse the response
var batchResult BatchResult
err = json.Unmarshal([]byte(response.Content), &batchResult)
assert.NoError(t, err)
// Verify batch results
assert.Len(t, batchResult.Results, 2)
assert.Empty(t, batchResult.Results[0].Error)
assert.Contains(t, batchResult.Results[1].Error, "tool not found: nonexistent")
})
t.Run("empty calls", func(t *testing.T) {
t.Parallel()
// Create batch tool with empty tools map
batchTool := NewBatchTool(map[string]BaseTool{})
// Create batch call with empty calls
input := `{
"calls": []
}`
call := ToolCall{
ID: "test-batch",
Name: "batch",
Input: input,
}
// Execute batch
response, err := batchTool.Run(context.Background(), call)
// Verify results
assert.NoError(t, err)
assert.Equal(t, ToolResponseTypeText, response.Type)
assert.True(t, response.IsError)
assert.Contains(t, response.Content, "no tool calls provided")
})
t.Run("invalid input", func(t *testing.T) {
t.Parallel()
// Create batch tool with empty tools map
batchTool := NewBatchTool(map[string]BaseTool{})
// Create batch call with invalid JSON
input := `{
"calls": [
{
"name": "tool1",
"input": {
"invalid": json
}
}
]
}`
call := ToolCall{
ID: "test-batch",
Name: "batch",
Input: input,
}
// Execute batch
response, err := batchTool.Run(context.Background(), call)
// Verify results
assert.NoError(t, err)
assert.Equal(t, ToolResponseTypeText, response.Type)
assert.True(t, response.IsError)
assert.Contains(t, response.Content, "error parsing parameters")
})
}
+3 -18
View File
@@ -196,16 +196,11 @@ func (e *editTool) createNewFile(ctx context.Context, filePath, content string)
content, content,
filePath, filePath,
) )
rootDir := config.WorkingDirectory()
permissionPath := filepath.Dir(filePath)
if strings.HasPrefix(filePath, rootDir) {
permissionPath = rootDir
}
p := e.permissions.Request( p := e.permissions.Request(
ctx, ctx,
permission.CreatePermissionRequest{ permission.CreatePermissionRequest{
SessionID: sessionID, SessionID: sessionID,
Path: permissionPath, Path: filePath,
ToolName: EditToolName, ToolName: EditToolName,
Action: "write", Action: "write",
Description: fmt.Sprintf("Create file %s", filePath), Description: fmt.Sprintf("Create file %s", filePath),
@@ -308,16 +303,11 @@ func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string
filePath, filePath,
) )
rootDir := config.WorkingDirectory()
permissionPath := filepath.Dir(filePath)
if strings.HasPrefix(filePath, rootDir) {
permissionPath = rootDir
}
p := e.permissions.Request( p := e.permissions.Request(
ctx, ctx,
permission.CreatePermissionRequest{ permission.CreatePermissionRequest{
SessionID: sessionID, SessionID: sessionID,
Path: permissionPath, Path: filePath,
ToolName: EditToolName, ToolName: EditToolName,
Action: "write", Action: "write",
Description: fmt.Sprintf("Delete content from file %s", filePath), Description: fmt.Sprintf("Delete content from file %s", filePath),
@@ -429,16 +419,11 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS
newContent, newContent,
filePath, filePath,
) )
rootDir := config.WorkingDirectory()
permissionPath := filepath.Dir(filePath)
if strings.HasPrefix(filePath, rootDir) {
permissionPath = rootDir
}
p := e.permissions.Request( p := e.permissions.Request(
ctx, ctx,
permission.CreatePermissionRequest{ permission.CreatePermissionRequest{
SessionID: sessionID, SessionID: sessionID,
Path: permissionPath, Path: filePath,
ToolName: EditToolName, ToolName: EditToolName,
Action: "write", Action: "write",
Description: fmt.Sprintf("Replace content in file %s", filePath), Description: fmt.Sprintf("Replace content in file %s", filePath),
+19 -142
View File
@@ -5,16 +5,14 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/fs"
"os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"time"
"github.com/bmatcuk/doublestar/v4"
"github.com/sst/opencode/internal/config" "github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/fileutil"
"github.com/sst/opencode/internal/status"
) )
const ( const (
@@ -55,11 +53,6 @@ TIPS:
- Always check if results are truncated and refine your search pattern if needed` - Always check if results are truncated and refine your search pattern if needed`
) )
type fileInfo struct {
path string
modTime time.Time
}
type GlobParams struct { type GlobParams struct {
Pattern string `json:"pattern"` Pattern string `json:"pattern"`
Path string `json:"path"` Path string `json:"path"`
@@ -134,41 +127,20 @@ func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
} }
func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) { func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
matches, err := globWithRipgrep(pattern, searchPath, limit) cmdRg := fileutil.GetRgCmd(pattern)
if err == nil { if cmdRg != nil {
return matches, len(matches) >= limit, nil cmdRg.Dir = searchPath
matches, err := runRipgrep(cmdRg, searchPath, limit)
if err == nil {
return matches, len(matches) >= limit && limit > 0, nil
}
status.Warn(fmt.Sprintf("Ripgrep execution failed: %v. Falling back to doublestar.", err))
} }
return globWithDoublestar(pattern, searchPath, limit) return fileutil.GlobWithDoublestar(pattern, searchPath, limit)
} }
func globWithRipgrep( func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) {
pattern, searchRoot string,
limit int,
) ([]string, error) {
if searchRoot == "" {
searchRoot = "."
}
rgBin, err := exec.LookPath("rg")
if err != nil {
return nil, fmt.Errorf("ripgrep not found in $PATH: %w", err)
}
if !filepath.IsAbs(pattern) && !strings.HasPrefix(pattern, "/") {
pattern = "/" + pattern
}
args := []string{
"--files",
"--null",
"--glob", pattern,
"-L",
}
cmd := exec.Command(rgBin, args...)
cmd.Dir = searchRoot
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if err != nil { if err != nil {
if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 { if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
@@ -182,117 +154,22 @@ func globWithRipgrep(
if len(p) == 0 { if len(p) == 0 {
continue continue
} }
abs := filepath.Join(searchRoot, string(p)) absPath := string(p)
if skipHidden(abs) { if !filepath.IsAbs(absPath) {
absPath = filepath.Join(searchRoot, absPath)
}
if fileutil.SkipHidden(absPath) {
continue continue
} }
matches = append(matches, abs) matches = append(matches, absPath)
} }
sort.SliceStable(matches, func(i, j int) bool { sort.SliceStable(matches, func(i, j int) bool {
return len(matches[i]) < len(matches[j]) return len(matches[i]) < len(matches[j])
}) })
if len(matches) > limit { if limit > 0 && len(matches) > limit {
matches = matches[:limit] matches = matches[:limit]
} }
return matches, nil return matches, nil
} }
func globWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) {
fsys := os.DirFS(searchPath)
relPattern := strings.TrimPrefix(pattern, "/")
var matches []fileInfo
err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
if d.IsDir() {
return nil
}
if skipHidden(path) {
return nil
}
info, err := d.Info()
if err != nil {
return nil // Skip files we can't access
}
absPath := path // Restore absolute path
if !strings.HasPrefix(absPath, searchPath) {
absPath = filepath.Join(searchPath, absPath)
}
matches = append(matches, fileInfo{
path: absPath,
modTime: info.ModTime(),
})
if len(matches) >= limit*2 { // Collect more than needed for sorting
return fs.SkipAll
}
return nil
})
if err != nil {
return nil, false, fmt.Errorf("glob walk error: %w", err)
}
sort.Slice(matches, func(i, j int) bool {
return matches[i].modTime.After(matches[j].modTime)
})
truncated := len(matches) > limit
if truncated {
matches = matches[:limit]
}
results := make([]string, len(matches))
for i, m := range matches {
results[i] = m.path
}
return results, truncated, nil
}
func skipHidden(path string) bool {
// Check for hidden files (starting with a dot)
base := filepath.Base(path)
if base != "." && strings.HasPrefix(base, ".") {
return true
}
// List of commonly ignored directories in development projects
commonIgnoredDirs := map[string]bool{
"node_modules": true,
"vendor": true,
"dist": true,
"build": true,
"target": true,
".git": true,
".idea": true,
".vscode": true,
"__pycache__": true,
"bin": true,
"obj": true,
"out": true,
"coverage": true,
"tmp": true,
"temp": true,
"logs": true,
"generated": true,
"bower_components": true,
"jspm_packages": true,
}
// Check if any path component is in our ignore list
parts := strings.SplitSeq(path, string(os.PathSeparator))
for part := range parts {
if commonIgnoredDirs[part] {
return true
}
}
return false
}
+2 -1
View File
@@ -15,6 +15,7 @@ import (
"time" "time"
"github.com/sst/opencode/internal/config" "github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/fileutil"
) )
type GrepParams struct { type GrepParams struct {
@@ -288,7 +289,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
return nil // Skip directories return nil // Skip directories
} }
if skipHidden(path) { if fileutil.SkipHidden(path) {
return nil return nil
} }
+350
View File
@@ -0,0 +1,350 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/lsp/protocol"
"github.com/sst/opencode/internal/lsp/util"
)
type CodeActionParams struct {
FilePath string `json:"file_path"`
Line int `json:"line"`
Column int `json:"column"`
EndLine int `json:"end_line,omitempty"`
EndColumn int `json:"end_column,omitempty"`
ActionID int `json:"action_id,omitempty"`
LspName string `json:"lsp_name,omitempty"`
}
type codeActionTool struct {
lspClients map[string]*lsp.Client
}
const (
CodeActionToolName = "codeAction"
codeActionDescription = `Get available code actions at a specific position or range in a file.
WHEN TO USE THIS TOOL:
- Use when you need to find available fixes or refactorings for code issues
- Helpful for resolving errors, warnings, or improving code quality
- Great for discovering automated code transformations
HOW TO USE:
- Provide the path to the file containing the code
- Specify the line number (1-based) where the action should be applied
- Specify the column number (1-based) where the action should be applied
- Optionally specify end_line and end_column to define a range
- Results show available code actions with their titles and kinds
TO EXECUTE A CODE ACTION:
- After getting the list of available actions, call the tool again with the same parameters
- Add action_id parameter with the number of the action you want to execute (e.g., 1 for the first action)
- Add lsp_name parameter with the name of the LSP server that provided the action
FEATURES:
- Finds quick fixes for errors and warnings
- Discovers available refactorings
- Shows code organization actions
- Returns detailed information about each action
- Can execute selected code actions
LIMITATIONS:
- Requires a functioning LSP server for the file type
- May not work for all code issues depending on LSP capabilities
- Results depend on the accuracy of the LSP server
TIPS:
- Use in conjunction with Diagnostics tool to find issues that can be fixed
- First call without action_id to see available actions, then call again with action_id to execute
`
)
func NewCodeActionTool(lspClients map[string]*lsp.Client) BaseTool {
return &codeActionTool{
lspClients,
}
}
func (b *codeActionTool) Info() ToolInfo {
return ToolInfo{
Name: CodeActionToolName,
Description: codeActionDescription,
Parameters: map[string]any{
"file_path": map[string]any{
"type": "string",
"description": "The path to the file containing the code",
},
"line": map[string]any{
"type": "integer",
"description": "The line number (1-based) where the action should be applied",
},
"column": map[string]any{
"type": "integer",
"description": "The column number (1-based) where the action should be applied",
},
"end_line": map[string]any{
"type": "integer",
"description": "The ending line number (1-based) for a range (optional)",
},
"end_column": map[string]any{
"type": "integer",
"description": "The ending column number (1-based) for a range (optional)",
},
"action_id": map[string]any{
"type": "integer",
"description": "The ID of the code action to execute (optional)",
},
"lsp_name": map[string]any{
"type": "string",
"description": "The name of the LSP server that provided the action (optional)",
},
},
Required: []string{"file_path", "line", "column"},
}
}
func (b *codeActionTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
var params CodeActionParams
if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
}
lsps := b.lspClients
if len(lsps) == 0 {
return NewTextResponse("\nLSP clients are still initializing. Code actions will be available once they're ready.\n"), nil
}
// Ensure file is open in LSP
notifyLspOpenFile(ctx, params.FilePath, lsps)
// Convert 1-based line/column to 0-based for LSP protocol
line := max(0, params.Line-1)
column := max(0, params.Column-1)
// Handle optional end line/column
endLine := line
endColumn := column
if params.EndLine > 0 {
endLine = max(0, params.EndLine-1)
}
if params.EndColumn > 0 {
endColumn = max(0, params.EndColumn-1)
}
// Check if we're executing a specific action
if params.ActionID > 0 && params.LspName != "" {
return executeCodeAction(ctx, params.FilePath, line, column, endLine, endColumn, params.ActionID, params.LspName, lsps)
}
// Otherwise, just list available actions
output := getCodeActions(ctx, params.FilePath, line, column, endLine, endColumn, lsps)
return NewTextResponse(output), nil
}
func getCodeActions(ctx context.Context, filePath string, line, column, endLine, endColumn int, lsps map[string]*lsp.Client) string {
var results []string
for lspName, client := range lsps {
// Create code action params
uri := fmt.Sprintf("file://%s", filePath)
codeActionParams := protocol.CodeActionParams{
TextDocument: protocol.TextDocumentIdentifier{
URI: protocol.DocumentUri(uri),
},
Range: protocol.Range{
Start: protocol.Position{
Line: uint32(line),
Character: uint32(column),
},
End: protocol.Position{
Line: uint32(endLine),
Character: uint32(endColumn),
},
},
Context: protocol.CodeActionContext{
// Request all kinds of code actions
Only: []protocol.CodeActionKind{
protocol.QuickFix,
protocol.Refactor,
protocol.RefactorExtract,
protocol.RefactorInline,
protocol.RefactorRewrite,
protocol.Source,
protocol.SourceOrganizeImports,
protocol.SourceFixAll,
},
},
}
// Get code actions
codeActions, err := client.CodeAction(ctx, codeActionParams)
if err != nil {
results = append(results, fmt.Sprintf("Error from %s: %s", lspName, err))
continue
}
if len(codeActions) == 0 {
results = append(results, fmt.Sprintf("No code actions found by %s", lspName))
continue
}
// Format the code actions
results = append(results, fmt.Sprintf("Code actions found by %s:", lspName))
for i, action := range codeActions {
actionInfo := formatCodeAction(action, i+1)
results = append(results, actionInfo)
}
}
if len(results) == 0 {
return "No code actions found at the specified position."
}
return strings.Join(results, "\n")
}
func formatCodeAction(action protocol.Or_Result_textDocument_codeAction_Item0_Elem, index int) string {
switch v := action.Value.(type) {
case protocol.CodeAction:
kind := "Unknown"
if v.Kind != "" {
kind = string(v.Kind)
}
var details []string
// Add edit information if available
if v.Edit != nil {
numChanges := 0
if v.Edit.Changes != nil {
numChanges = len(v.Edit.Changes)
}
if v.Edit.DocumentChanges != nil {
numChanges = len(v.Edit.DocumentChanges)
}
details = append(details, fmt.Sprintf("Edits: %d changes", numChanges))
}
// Add command information if available
if v.Command != nil {
details = append(details, fmt.Sprintf("Command: %s", v.Command.Title))
}
// Add diagnostics information if available
if v.Diagnostics != nil && len(v.Diagnostics) > 0 {
details = append(details, fmt.Sprintf("Fixes: %d diagnostics", len(v.Diagnostics)))
}
detailsStr := ""
if len(details) > 0 {
detailsStr = " (" + strings.Join(details, ", ") + ")"
}
return fmt.Sprintf(" %d. %s [%s]%s", index, v.Title, kind, detailsStr)
case protocol.Command:
return fmt.Sprintf(" %d. %s [Command]", index, v.Title)
}
return fmt.Sprintf(" %d. Unknown code action type", index)
}
func executeCodeAction(ctx context.Context, filePath string, line, column, endLine, endColumn, actionID int, lspName string, lsps map[string]*lsp.Client) (ToolResponse, error) {
client, ok := lsps[lspName]
if !ok {
return NewTextErrorResponse(fmt.Sprintf("LSP server '%s' not found", lspName)), nil
}
// Create code action params
uri := fmt.Sprintf("file://%s", filePath)
codeActionParams := protocol.CodeActionParams{
TextDocument: protocol.TextDocumentIdentifier{
URI: protocol.DocumentUri(uri),
},
Range: protocol.Range{
Start: protocol.Position{
Line: uint32(line),
Character: uint32(column),
},
End: protocol.Position{
Line: uint32(endLine),
Character: uint32(endColumn),
},
},
Context: protocol.CodeActionContext{
// Request all kinds of code actions
Only: []protocol.CodeActionKind{
protocol.QuickFix,
protocol.Refactor,
protocol.RefactorExtract,
protocol.RefactorInline,
protocol.RefactorRewrite,
protocol.Source,
protocol.SourceOrganizeImports,
protocol.SourceFixAll,
},
},
}
// Get code actions
codeActions, err := client.CodeAction(ctx, codeActionParams)
if err != nil {
return NewTextErrorResponse(fmt.Sprintf("Error getting code actions: %s", err)), nil
}
if len(codeActions) == 0 {
return NewTextErrorResponse("No code actions found"), nil
}
// Check if the requested action ID is valid
if actionID < 1 || actionID > len(codeActions) {
return NewTextErrorResponse(fmt.Sprintf("Invalid action ID: %d. Available actions: 1-%d", actionID, len(codeActions))), nil
}
// Get the selected action (adjust for 0-based index)
selectedAction := codeActions[actionID-1]
// Execute the action based on its type
switch v := selectedAction.Value.(type) {
case protocol.CodeAction:
// Apply workspace edit if available
if v.Edit != nil {
err := util.ApplyWorkspaceEdit(*v.Edit)
if err != nil {
return NewTextErrorResponse(fmt.Sprintf("Error applying edit: %s", err)), nil
}
}
// Execute command if available
if v.Command != nil {
_, err := client.ExecuteCommand(ctx, protocol.ExecuteCommandParams{
Command: v.Command.Command,
Arguments: v.Command.Arguments,
})
if err != nil {
return NewTextErrorResponse(fmt.Sprintf("Error executing command: %s", err)), nil
}
}
return NewTextResponse(fmt.Sprintf("Successfully executed code action: %s", v.Title)), nil
case protocol.Command:
// Execute the command
_, err := client.ExecuteCommand(ctx, protocol.ExecuteCommandParams{
Command: v.Command,
Arguments: v.Arguments,
})
if err != nil {
return NewTextErrorResponse(fmt.Sprintf("Error executing command: %s", err)), nil
}
return NewTextResponse(fmt.Sprintf("Successfully executed command: %s", v.Title)), nil
}
return NewTextErrorResponse("Unknown code action type"), nil
}
+20 -4
View File
@@ -12,6 +12,7 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/status" "github.com/sst/opencode/internal/status"
) )
@@ -59,12 +60,27 @@ func GetPersistentShell(workingDir string) *PersistentShell {
} }
func newPersistentShell(cwd string) *PersistentShell { func newPersistentShell(cwd string) *PersistentShell {
shellPath := os.Getenv("SHELL") cfg := config.Get()
if shellPath == "" {
shellPath = "/bin/bash" // 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 cmd.Dir = cwd
stdinPipe, err := cmd.StdinPipe() stdinPipe, err := cmd.StdinPipe()
+1 -7
View File
@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/sst/opencode/internal/config" "github.com/sst/opencode/internal/config"
@@ -161,16 +160,11 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
filePath, filePath,
) )
rootDir := config.WorkingDirectory()
permissionPath := filepath.Dir(filePath)
if strings.HasPrefix(filePath, rootDir) {
permissionPath = rootDir
}
p := w.permissions.Request( p := w.permissions.Request(
ctx, ctx,
permission.CreatePermissionRequest{ permission.CreatePermissionRequest{
SessionID: sessionID, SessionID: sessionID,
Path: permissionPath, Path: filePath,
ToolName: WriteToolName, ToolName: WriteToolName,
Action: "write", Action: "write",
Description: fmt.Sprintf("Create file %s", filePath), Description: fmt.Sprintf("Create file %s", filePath),
+49 -24
View File
@@ -20,9 +20,28 @@ const (
) )
type StatusMessage struct { type StatusMessage struct {
Level Level `json:"level"` Level Level `json:"level"`
Message string `json:"message"` Message string `json:"message"`
Timestamp time.Time `json:"timestamp"` 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 ( const (
@@ -32,10 +51,10 @@ const (
type Service interface { type Service interface {
pubsub.Subscriber[StatusMessage] pubsub.Subscriber[StatusMessage]
Info(message string) Info(message string, opts ...StatusOption)
Warn(message string) Warn(message string, opts ...StatusOption)
Error(message string) Error(message string, opts ...StatusOption)
Debug(message string) Debug(message string, opts ...StatusOption)
} }
type service struct { type service struct {
@@ -63,32 +82,38 @@ func GetService() Service {
return globalStatusService return globalStatusService
} }
func (s *service) Info(message string) { func (s *service) Info(message string, opts ...StatusOption) {
s.publish(LevelInfo, message) s.publish(LevelInfo, message, opts...)
slog.Info(message) slog.Info(message)
} }
func (s *service) Warn(message string) { func (s *service) Warn(message string, opts ...StatusOption) {
s.publish(LevelWarn, message) s.publish(LevelWarn, message, opts...)
slog.Warn(message) slog.Warn(message)
} }
func (s *service) Error(message string) { func (s *service) Error(message string, opts ...StatusOption) {
s.publish(LevelError, message) s.publish(LevelError, message, opts...)
slog.Error(message) slog.Error(message)
} }
func (s *service) Debug(message string) { func (s *service) Debug(message string, opts ...StatusOption) {
s.publish(LevelDebug, message) s.publish(LevelDebug, message, opts...)
slog.Debug(message) slog.Debug(message)
} }
func (s *service) publish(level Level, messageText string) { func (s *service) publish(level Level, messageText string, opts ...StatusOption) {
statusMsg := StatusMessage{ statusMsg := StatusMessage{
Level: level, Level: level,
Message: messageText, Message: messageText,
Timestamp: time.Now(), Timestamp: time.Now(),
} }
// Apply all options
for _, opt := range opts {
opt(&statusMsg)
}
s.broker.Publish(EventStatusPublished, 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) return s.broker.Subscribe(ctx)
} }
func Info(message string) { func Info(message string, opts ...StatusOption) {
GetService().Info(message) GetService().Info(message, opts...)
} }
func Warn(message string) { func Warn(message string, opts ...StatusOption) {
GetService().Warn(message) GetService().Warn(message, opts...)
} }
func Error(message string) { func Error(message string, opts ...StatusOption) {
GetService().Error(message) GetService().Error(message, opts...)
} }
func Debug(message string) { func Debug(message string, opts ...StatusOption) {
GetService().Debug(message) GetService().Debug(message, opts...)
} }
func Subscribe(ctx context.Context) <-chan pubsub.Event[StatusMessage] { func Subscribe(ctx context.Context) <-chan pubsub.Event[StatusMessage] {
+108 -10
View File
@@ -2,9 +2,11 @@ package chat
import ( import (
"fmt" "fmt"
"log/slog"
"os" "os"
"os/exec" "os/exec"
"slices" "slices"
"strings"
"unicode" "unicode"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
@@ -15,6 +17,7 @@ import (
"github.com/sst/opencode/internal/message" "github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/status" "github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/tui/components/dialog" "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/layout"
"github.com/sst/opencode/internal/tui/styles" "github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme" "github.com/sst/opencode/internal/tui/theme"
@@ -22,17 +25,23 @@ import (
) )
type editorCmp struct { type editorCmp struct {
width int width int
height int height int
app *app.App app *app.App
textarea textarea.Model textarea textarea.Model
attachments []message.Attachment attachments []message.Attachment
deleteMode bool deleteMode bool
history []string
historyIndex int
currentMessage string
} }
type EditorKeyMaps struct { type EditorKeyMaps struct {
Send key.Binding Send key.Binding
OpenEditor key.Binding OpenEditor key.Binding
Paste key.Binding
HistoryUp key.Binding
HistoryDown key.Binding
} }
type bluredEditorKeyMaps struct { type bluredEditorKeyMaps struct {
@@ -55,6 +64,18 @@ var editorMaps = EditorKeyMaps{
key.WithKeys("ctrl+e"), key.WithKeys("ctrl+e"),
key.WithHelp("ctrl+e", "open editor"), key.WithHelp("ctrl+e", "open editor"),
), ),
Paste: key.NewBinding(
key.WithKeys("ctrl+v"),
key.WithHelp("ctrl+v", "paste content"),
),
HistoryUp: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("up", "previous message"),
),
HistoryDown: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("down", "next message"),
),
} }
var DeleteKeyMaps = DeleteAttachmentKeyMaps{ var DeleteKeyMaps = DeleteAttachmentKeyMaps{
@@ -68,7 +89,7 @@ var DeleteKeyMaps = DeleteAttachmentKeyMaps{
), ),
DeleteAllAttachments: key.NewBinding( DeleteAllAttachments: key.NewBinding(
key.WithKeys("r"), key.WithKeys("r"),
key.WithHelp("ctrl+r+r", "delete all attchments"), key.WithHelp("ctrl+r+r", "delete all attachments"),
), ),
} }
@@ -131,6 +152,15 @@ func (m *editorCmp) send() tea.Cmd {
m.textarea.Reset() m.textarea.Reset()
attachments := m.attachments attachments := m.attachments
// Save to history if not empty and not a duplicate of the last entry
if value != "" {
if len(m.history) == 0 || m.history[len(m.history)-1] != value {
m.history = append(m.history, value)
}
m.historyIndex = len(m.history)
m.currentMessage = ""
}
m.attachments = nil m.attachments = nil
if value == "" { if value == "" {
return nil return nil
@@ -148,6 +178,11 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case dialog.ThemeChangedMsg: case dialog.ThemeChangedMsg:
m.textarea = CreateTextArea(&m.textarea) m.textarea = CreateTextArea(&m.textarea)
case dialog.CompletionSelectedMsg:
existingValue := m.textarea.Value()
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
m.textarea.SetValue(modifiedValue)
return m, nil return m, nil
case dialog.AttachmentAddedMsg: case dialog.AttachmentAddedMsg:
if len(m.attachments) >= maxAttachments { if len(m.attachments) >= maxAttachments {
@@ -194,6 +229,67 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.deleteMode = false m.deleteMode = false
return m, nil 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 history navigation with up/down arrow keys
// Only handle history navigation if the filepicker is not open
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryUp) && !m.app.IsFilepickerOpen() {
// Get the current line number
currentLine := m.textarea.Line()
// Only navigate history if we're at the first line
if currentLine == 0 && len(m.history) > 0 {
// Save current message if we're just starting to navigate
if m.historyIndex == len(m.history) {
m.currentMessage = m.textarea.Value()
}
// Go to previous message in history
if m.historyIndex > 0 {
m.historyIndex--
m.textarea.SetValue(m.history[m.historyIndex])
}
return m, nil
}
}
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryDown) && !m.app.IsFilepickerOpen() {
// Get the current line number and total lines
currentLine := m.textarea.Line()
value := m.textarea.Value()
lines := strings.Split(value, "\n")
totalLines := len(lines)
// Only navigate history if we're at the last line
if currentLine == totalLines-1 {
if m.historyIndex < len(m.history)-1 {
// Go to next message in history
m.historyIndex++
m.textarea.SetValue(m.history[m.historyIndex])
} else if m.historyIndex == len(m.history)-1 {
// Return to the current message being composed
m.historyIndex = len(m.history)
m.textarea.SetValue(m.currentMessage)
}
return m, nil
}
}
// Handle Enter key // Handle Enter key
if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) { if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
value := m.textarea.Value() value := m.textarea.Value()
@@ -237,7 +333,6 @@ func (m *editorCmp) SetSize(width, height int) tea.Cmd {
m.height = height m.height = height
m.textarea.SetWidth(width - 3) // account for the prompt and padding right m.textarea.SetWidth(width - 3) // account for the prompt and padding right
m.textarea.SetHeight(height) m.textarea.SetHeight(height)
m.textarea.SetWidth(width)
return nil return nil
} }
@@ -308,7 +403,10 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
func NewEditorCmp(app *app.App) tea.Model { func NewEditorCmp(app *app.App) tea.Model {
ta := CreateTextArea(nil) ta := CreateTextArea(nil)
return &editorCmp{ return &editorCmp{
app: app, app: app,
textarea: ta, textarea: ta,
history: []string{},
historyIndex: 0,
currentMessage: "",
} }
} }
+40
View File
@@ -266,6 +266,8 @@ func toolName(name string) string {
return "Write" return "Write"
case tools.PatchToolName: case tools.PatchToolName:
return "Patch" return "Patch"
case tools.BatchToolName:
return "Batch"
} }
return name return name
} }
@@ -292,6 +294,8 @@ func getToolAction(name string) string {
return "Preparing write..." return "Preparing write..."
case tools.PatchToolName: case tools.PatchToolName:
return "Preparing patch..." return "Preparing patch..."
case tools.BatchToolName:
return "Running batch operations..."
} }
return "Working..." return "Working..."
} }
@@ -443,6 +447,10 @@ func renderToolParams(paramWidth int, toolCall message.ToolCall) string {
json.Unmarshal([]byte(toolCall.Input), &params) json.Unmarshal([]byte(toolCall.Input), &params)
filePath := removeWorkingDirPrefix(params.FilePath) filePath := removeWorkingDirPrefix(params.FilePath)
return renderParams(paramWidth, filePath) return renderParams(paramWidth, filePath)
case tools.BatchToolName:
var params tools.BatchParams
json.Unmarshal([]byte(toolCall.Input), &params)
return renderParams(paramWidth, fmt.Sprintf("%d parallel calls", len(params.Calls)))
default: default:
input := strings.ReplaceAll(toolCall.Input, "\n", " ") input := strings.ReplaceAll(toolCall.Input, "\n", " ")
params = renderParams(paramWidth, input) params = renderParams(paramWidth, input)
@@ -540,6 +548,38 @@ func renderToolResponse(toolCall message.ToolCall, response message.ToolResult,
toMarkdown(resultContent, true, width), toMarkdown(resultContent, true, width),
t.Background(), 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: default:
resultContent = fmt.Sprintf("```text\n%s\n```", resultContent) resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
return styles.ForceReplaceBackgroundWithLipgloss( return styles.ForceReplaceBackgroundWithLipgloss(
@@ -386,6 +386,8 @@ func (m *messagesCmp) help() string {
baseStyle.Foreground(t.TextMuted()).Bold(true).Render("+"), baseStyle.Foreground(t.TextMuted()).Bold(true).Render("+"),
baseStyle.Foreground(t.Text()).Bold(true).Render("enter"), baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for newline,"), baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for newline,"),
baseStyle.Foreground(t.Text()).Bold(true).Render(" ↑↓"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for history,"),
baseStyle.Foreground(t.Text()).Bold(true).Render(" ctrl+h"), baseStyle.Foreground(t.Text()).Bold(true).Render(" ctrl+h"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to toggle tool messages"), baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to toggle tool messages"),
) )
+2 -8
View File
@@ -71,8 +71,7 @@ func (m *sidebarCmp) View() string {
return baseStyle. return baseStyle.
Width(m.width). Width(m.width).
PaddingLeft(4). PaddingLeft(4).
PaddingRight(2). PaddingRight(1).
Height(m.height - 1).
Render( Render(
lipgloss.JoinVertical( lipgloss.JoinVertical(
lipgloss.Top, lipgloss.Top,
@@ -98,14 +97,9 @@ func (m *sidebarCmp) sessionSection() string {
sessionValue := baseStyle. sessionValue := baseStyle.
Foreground(t.Text()). Foreground(t.Text()).
Width(m.width - lipgloss.Width(sessionKey)).
Render(fmt.Sprintf(": %s", m.app.CurrentSession.Title)) Render(fmt.Sprintf(": %s", m.app.CurrentSession.Title))
return lipgloss.JoinHorizontal( return sessionKey + sessionValue
lipgloss.Left,
sessionKey,
sessionValue,
)
} }
func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string { func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
+92 -35
View File
@@ -24,17 +24,11 @@ type StatusCmp interface {
} }
type statusCmp struct { type statusCmp struct {
app *app.App app *app.App
statusMessages []statusMessage queue []status.StatusMessage
width int width int
messageTTL time.Duration messageTTL time.Duration
} activeUntil time.Time
type statusMessage struct {
Level status.Level
Message string
Timestamp time.Time
ExpiresAt time.Time
} }
// clearMessageCmd is a command that clears status messages after a timeout // 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 return m, nil
case pubsub.Event[status.StatusMessage]: case pubsub.Event[status.StatusMessage]:
if msg.Type == status.EventStatusPublished { if msg.Type == status.EventStatusPublished {
statusMsg := statusMessage{ // If this is a critical message, move it to the front of the queue
Level: msg.Payload.Level, if msg.Payload.Critical {
Message: msg.Payload.Message, // Insert at the front of the queue
Timestamp: msg.Payload.Timestamp, m.queue = append([]status.StatusMessage{msg.Payload}, m.queue...)
ExpiresAt: msg.Payload.Timestamp.Add(m.messageTTL),
// 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: case statusCleanupMsg:
// Remove expired messages now := msg.time
var activeMessages []statusMessage
for _, sm := range m.statusMessages { // If the active message has expired, remove it and activate the next one
if sm.ExpiresAt.After(msg.time) { if !m.activeUntil.IsZero() && m.activeUntil.Before(now) {
activeMessages = append(activeMessages, sm) // 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, m.clearMessageCmd()
} }
return m, nil return m, nil
@@ -155,12 +176,14 @@ func (m statusCmp) View() string {
lipgloss.Width(diagnostics), lipgloss.Width(diagnostics),
) )
const minInlineWidth = 30
// Display the first status message if available // Display the first status message if available
if len(m.statusMessages) > 0 { var statusMessage string
sm := m.statusMessages[0] if len(m.queue) > 0 {
sm := m.queue[0]
infoStyle := styles.Padded(). infoStyle := styles.Padded().
Foreground(t.Background()). Foreground(t.Background())
Width(statusWidth)
switch sm.Level { switch sm.Level {
case "info": case "info":
@@ -176,11 +199,27 @@ func (m statusCmp) View() string {
// Truncate message if it's longer than available width // Truncate message if it's longer than available width
msg := sm.Message msg := sm.Message
availWidth := statusWidth - 10 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 { } else {
status += styles.Padded(). status += styles.Padded().
Foreground(t.Text()). Foreground(t.Text()).
@@ -191,7 +230,14 @@ func (m statusCmp) View() string {
status += diagnostics status += diagnostics
status += modelName 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 { 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{} diagnostics := []string{}
errStr := lipgloss.NewStyle(). errStr := lipgloss.NewStyle().
@@ -293,9 +349,10 @@ func NewStatusCmp(app *app.App) StatusCmp {
helpWidget = getHelpWidget("") helpWidget = getHelpWidget("")
statusComponent := &statusCmp{ statusComponent := &statusCmp{
app: app, app: app,
statusMessages: []statusMessage{}, queue: []status.StatusMessage{},
messageTTL: 4 * time.Second, messageTTL: 4 * time.Second,
activeUntil: time.Time{},
} }
return statusComponent return statusComponent
+155 -70
View File
@@ -1,6 +1,7 @@
package dialog package dialog
import ( import (
"fmt"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -11,35 +12,6 @@ import (
"github.com/sst/opencode/internal/tui/util" "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 { type argumentsDialogKeyMap struct {
Enter key.Binding Enter key.Binding
Escape key.Binding Escape key.Binding
@@ -64,77 +36,204 @@ func (k argumentsDialogKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{k.ShortHelp()} 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. // Init implements tea.Model.
func (m ArgumentsDialogCmp) Init() tea.Cmd { func (m MultiArgumentsDialogCmp) Init() tea.Cmd {
return tea.Batch( // Make sure only the first input is focused
textinput.Blink, for i := range m.inputs {
m.textInput.Focus(), if i == 0 {
) m.inputs[i].Focus()
} else {
m.inputs[i].Blur()
}
}
return textinput.Blink
} }
// Update implements tea.Model. // Update implements tea.Model.
func (m ArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m MultiArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd var cmds []tea.Cmd
t := theme.CurrentTheme()
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyMsg:
switch { switch {
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))): case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
return m, util.CmdHandler(CloseArgumentsDialogMsg{}) return m, util.CmdHandler(CloseMultiArgumentsDialogMsg{
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): Submit: false,
return m, util.CmdHandler(CloseArgumentsDialogMsg{
Submit: true,
CommandID: m.commandID, CommandID: m.commandID,
Content: m.content, 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: case tea.WindowSizeMsg:
m.width = msg.Width m.width = msg.Width
m.height = msg.Height 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) cmds = append(cmds, cmd)
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
} }
// View implements tea.Model. // View implements tea.Model.
func (m ArgumentsDialogCmp) View() string { func (m MultiArgumentsDialogCmp) View() string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
baseStyle := styles.BaseStyle() baseStyle := styles.BaseStyle()
// Calculate width needed for content // Calculate width needed for content
maxWidth := 60 // Width for explanation text maxWidth := 60 // Width for explanation text
title := baseStyle. title := lipgloss.NewStyle().
Foreground(t.Primary()). Foreground(t.Primary()).
Bold(true). Bold(true).
Width(maxWidth). Width(maxWidth).
Padding(0, 1). Padding(0, 1).
Background(t.Background()).
Render("Command Arguments") Render("Command Arguments")
explanation := baseStyle. explanation := lipgloss.NewStyle().
Foreground(t.Text()). Foreground(t.Text()).
Width(maxWidth). Width(maxWidth).
Padding(0, 1). 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. // Create input fields for each argument
Foreground(t.Text()). inputFields := make([]string, len(m.inputs))
Width(maxWidth). for i, input := range m.inputs {
Padding(1, 1). // Highlight the label of the focused input
Render(m.textInput.View()) 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) maxWidth = min(maxWidth, m.width-10)
// Join all elements vertically
elements := []string{title, explanation}
elements = append(elements, inputFields...)
content := lipgloss.JoinVertical( content := lipgloss.JoinVertical(
lipgloss.Left, lipgloss.Left,
title, elements...,
explanation,
inputField,
) )
return baseStyle.Padding(1, 2). return baseStyle.Padding(1, 2).
@@ -147,26 +246,12 @@ func (m ArgumentsDialogCmp) View() string {
} }
// SetSize sets the size of the component. // 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.width = width
m.height = height m.height = height
} }
// Bindings implements layout.Bindings. // Bindings implements layout.Bindings.
func (m ArgumentsDialogCmp) Bindings() []key.Binding { func (m MultiArgumentsDialogCmp) Bindings() []key.Binding {
return m.keys.ShortHelp() 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
}
+61 -130
View File
@@ -4,6 +4,7 @@ import (
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "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/layout"
"github.com/sst/opencode/internal/tui/styles" "github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme" "github.com/sst/opencode/internal/tui/theme"
@@ -18,6 +19,33 @@ type Command struct {
Handler func(cmd Command) tea.Cmd Handler func(cmd Command) tea.Cmd
} }
func (ci Command) Render(selected bool, width int) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
descStyle := baseStyle.Width(width).Foreground(t.TextMuted())
itemStyle := baseStyle.Width(width).
Foreground(t.Text()).
Background(t.Background())
if selected {
itemStyle = itemStyle.
Background(t.Primary()).
Foreground(t.Background()).
Bold(true)
descStyle = descStyle.
Background(t.Primary()).
Foreground(t.Background())
}
title := itemStyle.Padding(0, 1).Render(ci.Title)
if ci.Description != "" {
description := descStyle.Padding(0, 1).Render(ci.Description)
return lipgloss.JoinVertical(lipgloss.Left, title, description)
}
return title
}
// CommandSelectedMsg is sent when a command is selected // CommandSelectedMsg is sent when a command is selected
type CommandSelectedMsg struct { type CommandSelectedMsg struct {
Command Command Command Command
@@ -31,35 +59,20 @@ type CommandDialog interface {
tea.Model tea.Model
layout.Bindings layout.Bindings
SetCommands(commands []Command) SetCommands(commands []Command)
SetSelectedCommand(commandID string)
} }
type commandDialogCmp struct { type commandDialogCmp struct {
commands []Command listView utilComponents.SimpleList[Command]
selectedIdx int width int
width int height int
height int
selectedCommandID string
} }
type commandKeyMap struct { type commandKeyMap struct {
Up key.Binding
Down key.Binding
Enter key.Binding Enter key.Binding
Escape key.Binding Escape key.Binding
J key.Binding
K key.Binding
} }
var commandKeys = commandKeyMap{ var commandKeys = commandKeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "previous command"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("↓", "next command"),
),
Enter: key.NewBinding( Enter: key.NewBinding(
key.WithKeys("enter"), key.WithKeys("enter"),
key.WithHelp("enter", "select command"), key.WithHelp("enter", "select command"),
@@ -68,38 +81,22 @@ var commandKeys = commandKeyMap{
key.WithKeys("esc"), key.WithKeys("esc"),
key.WithHelp("esc", "close"), key.WithHelp("esc", "close"),
), ),
J: key.NewBinding(
key.WithKeys("j"),
key.WithHelp("j", "next command"),
),
K: key.NewBinding(
key.WithKeys("k"),
key.WithHelp("k", "previous command"),
),
} }
func (c *commandDialogCmp) Init() tea.Cmd { func (c *commandDialogCmp) Init() tea.Cmd {
return nil return c.listView.Init()
} }
func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyMsg:
switch { switch {
case key.Matches(msg, commandKeys.Up) || key.Matches(msg, commandKeys.K):
if c.selectedIdx > 0 {
c.selectedIdx--
}
return c, nil
case key.Matches(msg, commandKeys.Down) || key.Matches(msg, commandKeys.J):
if c.selectedIdx < len(c.commands)-1 {
c.selectedIdx++
}
return c, nil
case key.Matches(msg, commandKeys.Enter): case key.Matches(msg, commandKeys.Enter):
if len(c.commands) > 0 { selectedItem, idx := c.listView.GetSelectedItem()
if idx != -1 {
return c, util.CmdHandler(CommandSelectedMsg{ return c, util.CmdHandler(CommandSelectedMsg{
Command: c.commands[c.selectedIdx], Command: selectedItem,
}) })
} }
case key.Matches(msg, commandKeys.Escape): case key.Matches(msg, commandKeys.Escape):
@@ -109,78 +106,35 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
c.width = msg.Width c.width = msg.Width
c.height = msg.Height c.height = msg.Height
} }
return c, nil
u, cmd := c.listView.Update(msg)
c.listView = u.(utilComponents.SimpleList[Command])
cmds = append(cmds, cmd)
return c, tea.Batch(cmds...)
} }
func (c *commandDialogCmp) View() string { func (c *commandDialogCmp) View() string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
baseStyle := styles.BaseStyle() baseStyle := styles.BaseStyle()
if len(c.commands) == 0 { maxWidth := 40
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(40).
Render("No commands available")
}
// Calculate max width needed for command titles commands := c.listView.GetItems()
maxWidth := 40 // Minimum width
for _, cmd := range c.commands { for _, cmd := range commands {
if len(cmd.Title) > maxWidth-4 { // Account for padding if len(cmd.Title) > maxWidth-4 {
maxWidth = len(cmd.Title) + 4 maxWidth = len(cmd.Title) + 4
} }
if len(cmd.Description) > maxWidth-4 {
maxWidth = len(cmd.Description) + 4
}
}
// Limit height to avoid taking up too much screen space
maxVisibleCommands := min(10, len(c.commands))
// Build the command list
commandItems := make([]string, 0, maxVisibleCommands)
startIdx := 0
// If we have more commands than can be displayed, adjust the start index
if len(c.commands) > maxVisibleCommands {
// Center the selected item when possible
halfVisible := maxVisibleCommands / 2
if c.selectedIdx >= halfVisible && c.selectedIdx < len(c.commands)-halfVisible {
startIdx = c.selectedIdx - halfVisible
} else if c.selectedIdx >= len(c.commands)-halfVisible {
startIdx = len(c.commands) - maxVisibleCommands
}
}
endIdx := min(startIdx+maxVisibleCommands, len(c.commands))
for i := startIdx; i < endIdx; i++ {
cmd := c.commands[i]
itemStyle := baseStyle.Width(maxWidth)
descStyle := baseStyle.Width(maxWidth).Foreground(t.TextMuted())
if i == c.selectedIdx {
itemStyle = itemStyle.
Background(t.Primary()).
Foreground(t.Background()).
Bold(true)
descStyle = descStyle.
Background(t.Primary()).
Foreground(t.Background())
}
title := itemStyle.Padding(0, 1).Render(cmd.Title)
description := ""
if cmd.Description != "" { if cmd.Description != "" {
description = descStyle.Padding(0, 1).Render(cmd.Description) if len(cmd.Description) > maxWidth-4 {
commandItems = append(commandItems, lipgloss.JoinVertical(lipgloss.Left, title, description)) maxWidth = len(cmd.Description) + 4
} else { }
commandItems = append(commandItems, title)
} }
} }
c.listView.SetMaxWidth(maxWidth)
title := baseStyle. title := baseStyle.
Foreground(t.Primary()). Foreground(t.Primary()).
Bold(true). Bold(true).
@@ -192,7 +146,7 @@ func (c *commandDialogCmp) View() string {
lipgloss.Left, lipgloss.Left,
title, title,
baseStyle.Width(maxWidth).Render(""), baseStyle.Width(maxWidth).Render(""),
baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, commandItems...)), baseStyle.Width(maxWidth).Render(c.listView.View()),
baseStyle.Width(maxWidth).Render(""), baseStyle.Width(maxWidth).Render(""),
) )
@@ -209,41 +163,18 @@ func (c *commandDialogCmp) BindingKeys() []key.Binding {
} }
func (c *commandDialogCmp) SetCommands(commands []Command) { func (c *commandDialogCmp) SetCommands(commands []Command) {
c.commands = commands c.listView.SetItems(commands)
// If we have a selected command ID, find its index
if c.selectedCommandID != "" {
for i, cmd := range commands {
if cmd.ID == c.selectedCommandID {
c.selectedIdx = i
return
}
}
}
// Default to first command if selected not found
c.selectedIdx = 0
}
func (c *commandDialogCmp) SetSelectedCommand(commandID string) {
c.selectedCommandID = commandID
// Update the selected index if commands are already loaded
if len(c.commands) > 0 {
for i, cmd := range c.commands {
if cmd.ID == commandID {
c.selectedIdx = i
return
}
}
}
} }
// NewCommandDialogCmp creates a new command selection dialog // NewCommandDialogCmp creates a new command selection dialog
func NewCommandDialogCmp() CommandDialog { func NewCommandDialogCmp() CommandDialog {
listView := utilComponents.NewSimpleList[Command](
[]Command{},
10,
"No commands available",
true,
)
return &commandDialogCmp{ return &commandDialogCmp{
commands: []Command{}, listView: listView,
selectedIdx: 0,
selectedCommandID: "",
} }
} }
+263
View File
@@ -0,0 +1,263 @@
package dialog
import (
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/status"
utilComponents "github.com/sst/opencode/internal/tui/components/util"
"github.com/sst/opencode/internal/tui/layout"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/internal/tui/util"
)
type CompletionItem struct {
title string
Title string
Value string
}
type CompletionItemI interface {
utilComponents.SimpleListItem
GetValue() string
DisplayValue() string
}
func (ci *CompletionItem) Render(selected bool, width int) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
itemStyle := baseStyle.
Width(width).
Padding(0, 1)
if selected {
itemStyle = itemStyle.
Background(t.Background()).
Foreground(t.Primary()).
Bold(true)
}
title := itemStyle.Render(
ci.GetValue(),
)
return title
}
func (ci *CompletionItem) DisplayValue() string {
return ci.Title
}
func (ci *CompletionItem) GetValue() string {
return ci.Value
}
func NewCompletionItem(completionItem CompletionItem) CompletionItemI {
return &completionItem
}
type CompletionProvider interface {
GetId() string
GetEntry() CompletionItemI
GetChildEntries(query string) ([]CompletionItemI, error)
}
type CompletionSelectedMsg struct {
SearchString string
CompletionValue string
}
type CompletionDialogCompleteItemMsg struct {
Value string
}
type CompletionDialogCloseMsg struct{}
type CompletionDialog interface {
tea.Model
layout.Bindings
SetWidth(width int)
}
type completionDialogCmp struct {
query string
completionProvider CompletionProvider
width int
height int
pseudoSearchTextArea textarea.Model
listView utilComponents.SimpleList[CompletionItemI]
}
type completionDialogKeyMap struct {
Complete key.Binding
Cancel key.Binding
}
var completionDialogKeys = completionDialogKeyMap{
Complete: key.NewBinding(
key.WithKeys("tab", "enter"),
),
Cancel: key.NewBinding(
key.WithKeys(" ", "esc", "backspace"),
),
}
func (c *completionDialogCmp) Init() tea.Cmd {
return nil
}
func (c *completionDialogCmp) complete(item CompletionItemI) tea.Cmd {
value := c.pseudoSearchTextArea.Value()
if value == "" {
return nil
}
return tea.Batch(
util.CmdHandler(CompletionSelectedMsg{
SearchString: value,
CompletionValue: item.GetValue(),
}),
c.close(),
)
}
func (c *completionDialogCmp) close() tea.Cmd {
c.listView.SetItems([]CompletionItemI{})
c.pseudoSearchTextArea.Reset()
c.pseudoSearchTextArea.Blur()
return util.CmdHandler(CompletionDialogCloseMsg{})
}
func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
if c.pseudoSearchTextArea.Focused() {
if !key.Matches(msg, completionDialogKeys.Complete) {
var cmd tea.Cmd
c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
cmds = append(cmds, cmd)
var query string
query = c.pseudoSearchTextArea.Value()
if query != "" {
query = query[1:]
}
if query != c.query {
items, err := c.completionProvider.GetChildEntries(query)
if err != nil {
status.Error(err.Error())
}
c.listView.SetItems(items)
c.query = query
}
u, cmd := c.listView.Update(msg)
c.listView = u.(utilComponents.SimpleList[CompletionItemI])
cmds = append(cmds, cmd)
}
switch {
case key.Matches(msg, completionDialogKeys.Complete):
item, i := c.listView.GetSelectedItem()
if i == -1 {
return c, nil
}
cmd := c.complete(item)
return c, cmd
case key.Matches(msg, completionDialogKeys.Cancel):
// Only close on backspace when there are no characters left
if msg.String() != "backspace" || len(c.pseudoSearchTextArea.Value()) <= 0 {
return c, c.close()
}
}
return c, tea.Batch(cmds...)
} else {
items, err := c.completionProvider.GetChildEntries("")
if err != nil {
status.Error(err.Error())
}
c.listView.SetItems(items)
c.pseudoSearchTextArea.SetValue(msg.String())
return c, c.pseudoSearchTextArea.Focus()
}
case tea.WindowSizeMsg:
c.width = msg.Width
c.height = msg.Height
}
return c, tea.Batch(cmds...)
}
func (c *completionDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
maxWidth := 40
completions := c.listView.GetItems()
for _, cmd := range completions {
title := cmd.DisplayValue()
if len(title) > maxWidth-4 {
maxWidth = len(title) + 4
}
}
c.listView.SetMaxWidth(maxWidth)
return baseStyle.Padding(0, 0).
Border(lipgloss.NormalBorder()).
BorderBottom(false).
BorderRight(false).
BorderLeft(false).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(c.width).
Render(c.listView.View())
}
func (c *completionDialogCmp) SetWidth(width int) {
c.width = width
}
func (c *completionDialogCmp) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(completionDialogKeys)
}
func NewCompletionDialogCmp(completionProvider CompletionProvider) CompletionDialog {
ti := textarea.New()
items, err := completionProvider.GetChildEntries("")
if err != nil {
status.Error(err.Error())
}
li := utilComponents.NewSimpleList(
items,
7,
"No file matches found",
false,
)
return &completionDialogCmp{
query: "",
completionProvider: completionProvider,
pseudoSearchTextArea: ti,
listView: li,
}
}
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -17,6 +18,9 @@ const (
ProjectCommandPrefix = "project:" 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 // LoadCustomCommands loads custom commands from both XDG_CONFIG_HOME and project data directory
func LoadCustomCommands() ([]Command, error) { func LoadCustomCommands() ([]Command, error) {
cfg := config.Get() cfg := config.Get()
@@ -133,18 +137,33 @@ func loadCommandsFromDir(commandsDir string, prefix string) ([]Command, error) {
Handler: func(cmd Command) tea.Cmd { Handler: func(cmd Command) tea.Cmd {
commandContent := string(content) commandContent := string(content)
// Check if the command contains $ARGUMENTS placeholder // Check for named arguments
if strings.Contains(commandContent, "$ARGUMENTS") { matches := namedArgPattern.FindAllStringSubmatch(commandContent, -1)
// Show arguments dialog if len(matches) > 0 {
return util.CmdHandler(ShowArgumentsDialogMsg{ // 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, CommandID: cmd.ID,
Content: commandContent, Content: commandContent,
ArgNames: argNames,
}) })
} }
// No arguments needed, run command directly // No arguments needed, run command directly
return util.CmdHandler(CommandRunCustomMsg{ return util.CmdHandler(CommandRunCustomMsg{
Content: commandContent, 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 // CommandRunCustomMsg is sent when a custom command is executed
type CommandRunCustomMsg struct { type CommandRunCustomMsg struct {
Content string 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)
}
}
}
+17 -5
View File
@@ -9,6 +9,9 @@ import (
"strings" "strings"
"time" "time"
"log/slog"
"github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
@@ -22,7 +25,6 @@ import (
"github.com/sst/opencode/internal/tui/styles" "github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme" "github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/internal/tui/util" "github.com/sst/opencode/internal/tui/util"
"log/slog"
) )
const ( const (
@@ -40,6 +42,7 @@ type FilePrickerKeyMap struct {
OpenFilePicker key.Binding OpenFilePicker key.Binding
Esc key.Binding Esc key.Binding
InsertCWD key.Binding InsertCWD key.Binding
Paste key.Binding
} }
var filePickerKeyMap = FilePrickerKeyMap{ var filePickerKeyMap = FilePrickerKeyMap{
@@ -75,6 +78,10 @@ var filePickerKeyMap = FilePrickerKeyMap{
key.WithKeys("i"), key.WithKeys("i"),
key.WithHelp("i", "manual path input"), key.WithHelp("i", "manual path input"),
), ),
Paste: key.NewBinding(
key.WithKeys("ctrl+v"),
key.WithHelp("ctrl+v", "paste file/directory path"),
),
} }
type filepickerCmp struct { type filepickerCmp struct {
@@ -213,6 +220,15 @@ func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
f.getCurrentFileBelowCursor() 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): case key.Matches(msg, filePickerKeyMap.OpenFilePicker):
f.dirs = readDir(f.cwdDetails.directory, false) f.dirs = readDir(f.cwdDetails.directory, false)
f.cursor = 0 f.cursor = 0
@@ -303,10 +319,6 @@ func (f *filepickerCmp) View() string {
} }
if file.IsDir() { if file.IsDir() {
filename = filename + "/" filename = filename + "/"
} else if isExtSupported(file.Name()) {
filename = filename
} else {
filename = filename
} }
files = append(files, itemStyle.Padding(0, 1).Render(filename)) files = append(files, itemStyle.Padding(0, 1).Render(filename))
-2
View File
@@ -10,7 +10,6 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/config" "github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/models" "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/layout"
"github.com/sst/opencode/internal/tui/styles" "github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme" "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) m.switchProvider(1)
} }
case key.Matches(msg, modelKeys.Enter): 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]}) return m, util.CmdHandler(ModelSelectedMsg{Model: m.models[m.selectedIdx]})
case key.Matches(msg, modelKeys.Escape): case key.Matches(msg, modelKeys.Escape):
return m, util.CmdHandler(CloseModelDialogMsg{}) return m, util.CmdHandler(CloseModelDialogMsg{})
+12 -1
View File
@@ -6,6 +6,7 @@ import (
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/diff" "github.com/sst/opencode/internal/diff"
"github.com/sst/opencode/internal/llm/tools" "github.com/sst/opencode/internal/llm/tools"
"github.com/sst/opencode/internal/permission" "github.com/sst/opencode/internal/permission"
@@ -13,6 +14,7 @@ import (
"github.com/sst/opencode/internal/tui/styles" "github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme" "github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/internal/tui/util" "github.com/sst/opencode/internal/tui/util"
"path/filepath"
"strings" "strings"
) )
@@ -204,10 +206,19 @@ func (p *permissionDialogCmp) renderHeader() string {
Render(fmt.Sprintf(": %s", p.permission.ToolName)) Render(fmt.Sprintf(": %s", p.permission.ToolName))
pathKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Path") 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. pathValue := baseStyle.
Foreground(t.Text()). Foreground(t.Text()).
Width(p.width - lipgloss.Width(pathKey)). Width(p.width - lipgloss.Width(pathKey)).
Render(fmt.Sprintf(": %s", p.permission.Path)) Render(fmt.Sprintf(": %s", relativePath))
headerParts := []string{ headerParts := []string{
lipgloss.JoinHorizontal( lipgloss.JoinHorizontal(
+178
View File
@@ -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,
}
}
+3 -3
View File
@@ -84,7 +84,7 @@ func (i *detailCmp) updateContent() {
messageStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Text()) messageStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Text())
content.WriteString(messageStyle.Render("Message:")) content.WriteString(messageStyle.Render("Message:"))
content.WriteString("\n") content.WriteString("\n")
content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(i.currentLog.Message)) content.WriteString(lipgloss.NewStyle().Padding(0, 2).Width(i.width).Render(i.currentLog.Message))
content.WriteString("\n\n") content.WriteString("\n\n")
// Attributes section // Attributes section
@@ -112,7 +112,7 @@ func (i *detailCmp) updateContent() {
valueStyle.Render(value), valueStyle.Render(value),
) )
content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(attrLine)) content.WriteString(lipgloss.NewStyle().Padding(0, 2).Width(i.width).Render(attrLine))
content.WriteString("\n") content.WriteString("\n")
} }
} }
@@ -123,7 +123,7 @@ func (i *detailCmp) updateContent() {
content.WriteString("\n") content.WriteString("\n")
content.WriteString(sessionStyle.Render("Session:")) content.WriteString(sessionStyle.Render("Session:"))
content.WriteString("\n") content.WriteString("\n")
content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(i.currentLog.SessionID)) content.WriteString(lipgloss.NewStyle().Padding(0, 2).Width(i.width).Render(i.currentLog.SessionID))
} }
i.viewport.SetContent(content.String()) i.viewport.SetContent(content.String())
-5
View File
@@ -52,11 +52,6 @@ func (i *tableCmp) fetchLogs() tea.Cmd {
logs, err = i.app.Logs.ListAll(ctx, logLimit) logs, err = i.app.Logs.ListAll(ctx, logLimit)
} else { } else {
logs, err = i.app.Logs.ListBySession(ctx, i.app.CurrentSession.ID) 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 { if err != nil {
+127
View File
@@ -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
}
+159
View File
@@ -0,0 +1,159 @@
package utilComponents
import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/tui/layout"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
)
type SimpleListItem interface {
Render(selected bool, width int) string
}
type SimpleList[T SimpleListItem] interface {
tea.Model
layout.Bindings
SetMaxWidth(maxWidth int)
GetSelectedItem() (item T, idx int)
SetItems(items []T)
GetItems() []T
}
type simpleListCmp[T SimpleListItem] struct {
fallbackMsg string
items []T
selectedIdx int
maxWidth int
maxVisibleItems int
useAlphaNumericKeys bool
width int
height int
}
type simpleListKeyMap struct {
Up key.Binding
Down key.Binding
UpAlpha key.Binding
DownAlpha key.Binding
}
var simpleListKeys = simpleListKeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "previous list item"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("↓", "next list item"),
),
UpAlpha: key.NewBinding(
key.WithKeys("k"),
key.WithHelp("k", "previous list item"),
),
DownAlpha: key.NewBinding(
key.WithKeys("j"),
key.WithHelp("j", "next list item"),
),
}
func (c *simpleListCmp[T]) Init() tea.Cmd {
return nil
}
func (c *simpleListCmp[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, simpleListKeys.Up) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.UpAlpha)):
if c.selectedIdx > 0 {
c.selectedIdx--
}
return c, nil
case key.Matches(msg, simpleListKeys.Down) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.DownAlpha)):
if c.selectedIdx < len(c.items)-1 {
c.selectedIdx++
}
return c, nil
}
}
return c, nil
}
func (c *simpleListCmp[T]) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(simpleListKeys)
}
func (c *simpleListCmp[T]) GetSelectedItem() (T, int) {
if len(c.items) > 0 {
return c.items[c.selectedIdx], c.selectedIdx
}
var zero T
return zero, -1
}
func (c *simpleListCmp[T]) SetItems(items []T) {
c.selectedIdx = 0
c.items = items
}
func (c *simpleListCmp[T]) GetItems() []T {
return c.items
}
func (c *simpleListCmp[T]) SetMaxWidth(width int) {
c.maxWidth = width
}
func (c *simpleListCmp[T]) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
items := c.items
maxWidth := c.maxWidth
maxVisibleItems := min(c.maxVisibleItems, len(items))
startIdx := 0
if len(items) <= 0 {
return baseStyle.
Background(t.Background()).
Padding(0, 1).
Width(maxWidth).
Render(c.fallbackMsg)
}
if len(items) > maxVisibleItems {
halfVisible := maxVisibleItems / 2
if c.selectedIdx >= halfVisible && c.selectedIdx < len(items)-halfVisible {
startIdx = c.selectedIdx - halfVisible
} else if c.selectedIdx >= len(items)-halfVisible {
startIdx = len(items) - maxVisibleItems
}
}
endIdx := min(startIdx+maxVisibleItems, len(items))
listItems := make([]string, 0, maxVisibleItems)
for i := startIdx; i < endIdx; i++ {
item := items[i]
title := item.Render(i == c.selectedIdx, maxWidth)
listItems = append(listItems, title)
}
return lipgloss.JoinVertical(lipgloss.Left, listItems...)
}
func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) SimpleList[T] {
return &simpleListCmp[T]{
fallbackMsg: fallbackMsg,
items: items,
maxVisibleItems: maxVisibleItems,
useAlphaNumericKeys: useAlphaNumericKeys,
selectedIdx: 0,
}
}
+49
View File
@@ -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
}
+192
View File
@@ -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])
}
+13
View File
@@ -1,14 +1,17 @@
package image package image
import ( import (
"bytes"
"fmt" "fmt"
"image" "image"
"image/png"
"os" "os"
"strings" "strings"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/disintegration/imaging" "github.com/disintegration/imaging"
"github.com/lucasb-eyer/go-colorful" "github.com/lucasb-eyer/go-colorful"
_ "golang.org/x/image/webp"
) )
func ValidateFileSize(filePath string, sizeLimit int64) (bool, error) { func ValidateFileSize(filePath string, sizeLimit int64) (bool, error) {
@@ -70,3 +73,13 @@ func ImagePreview(width int, filename string) (string, error) {
return imageString, nil 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
}
+4 -7
View File
@@ -11,16 +11,16 @@ type Container interface {
tea.Model tea.Model
Sizeable Sizeable
Bindings Bindings
Focus() // Add focus method Focus()
Blur() // Add blur method Blur()
} }
type container struct { type container struct {
width int width int
height int height int
content tea.Model content tea.Model
// Style options
paddingTop int paddingTop int
paddingRight int paddingRight int
paddingBottom int paddingBottom int
@@ -32,7 +32,7 @@ type container struct {
borderLeft bool borderLeft bool
borderStyle lipgloss.Border borderStyle lipgloss.Border
focused bool // Track focus state focused bool
} }
func (c *container) Init() tea.Cmd { func (c *container) Init() tea.Cmd {
@@ -152,16 +152,13 @@ func (c *container) Blur() {
type ContainerOption func(*container) type ContainerOption func(*container)
func NewContainer(content tea.Model, options ...ContainerOption) Container { func NewContainer(content tea.Model, options ...ContainerOption) Container {
c := &container{ c := &container{
content: content, content: content,
borderStyle: lipgloss.NormalBorder(), borderStyle: lipgloss.NormalBorder(),
} }
for _, option := range options { for _, option := range options {
option(c) option(c)
} }
return c return c
} }
+73 -12
View File
@@ -3,10 +3,13 @@ package page
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/completions"
"github.com/sst/opencode/internal/message" "github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/session" "github.com/sst/opencode/internal/session"
"github.com/sst/opencode/internal/status" "github.com/sst/opencode/internal/status"
@@ -20,16 +23,19 @@ import (
var ChatPage PageID = "chat" var ChatPage PageID = "chat"
type chatPage struct { type chatPage struct {
app *app.App app *app.App
editor layout.Container editor layout.Container
messages layout.Container messages layout.Container
layout layout.SplitPaneLayout layout layout.SplitPaneLayout
completionDialog dialog.CompletionDialog
showCompletionDialog bool
} }
type ChatKeyMap struct { type ChatKeyMap struct {
NewSession key.Binding NewSession key.Binding
Cancel key.Binding Cancel key.Binding
ToggleTools key.Binding ToggleTools key.Binding
ShowCompletionDialog key.Binding
} }
var keyMap = ChatKeyMap{ var keyMap = ChatKeyMap{
@@ -45,12 +51,17 @@ var keyMap = ChatKeyMap{
key.WithKeys("ctrl+h"), key.WithKeys("ctrl+h"),
key.WithHelp("ctrl+h", "toggle tools"), key.WithHelp("ctrl+h", "toggle tools"),
), ),
ShowCompletionDialog: key.NewBinding(
key.WithKeys("/"),
key.WithHelp("/", "Complete"),
),
} }
func (p *chatPage) Init() tea.Cmd { func (p *chatPage) Init() tea.Cmd {
cmds := []tea.Cmd{ cmds := []tea.Cmd{
p.layout.Init(), p.layout.Init(),
} }
cmds = append(cmds, p.completionDialog.Init())
return tea.Batch(cmds...) return tea.Batch(cmds...)
} }
@@ -71,8 +82,19 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
status.Warn("Agent is busy, please wait before executing a command...") status.Warn("Agent is busy, please wait before executing a command...")
return p, nil 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 // Handle custom command execution
cmd := p.sendMessage(msg.Content, nil) cmd := p.sendMessage(content, nil)
if cmd != nil { if cmd != nil {
return p, cmd return p, cmd
} }
@@ -99,8 +121,13 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}(p.app.CurrentSession.ID) }(p.app.CurrentSession.ID)
return p, nil return p, nil
case dialog.CompletionDialogCloseMsg:
p.showCompletionDialog = false
case tea.KeyMsg: case tea.KeyMsg:
switch { switch {
case key.Matches(msg, keyMap.ShowCompletionDialog):
p.showCompletionDialog = true
// Continue sending keys to layout->chat
case key.Matches(msg, keyMap.NewSession): case key.Matches(msg, keyMap.NewSession):
p.app.CurrentSession = &session.Session{} p.app.CurrentSession = &session.Session{}
return p, tea.Batch( return p, tea.Batch(
@@ -118,6 +145,19 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return p, util.CmdHandler(chat.ToggleToolMessagesMsg{}) return p, util.CmdHandler(chat.ToggleToolMessagesMsg{})
} }
} }
if p.showCompletionDialog {
context, contextCmd := p.completionDialog.Update(msg)
p.completionDialog = context.(dialog.CompletionDialog)
cmds = append(cmds, contextCmd)
// Doesn't forward event if enter key is pressed
if keyMsg, ok := msg.(tea.KeyMsg); ok {
if keyMsg.String() == "enter" {
return p, tea.Batch(cmds...)
}
}
}
u, cmd := p.layout.Update(msg) u, cmd := p.layout.Update(msg)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
p.layout = u.(layout.SplitPaneLayout) p.layout = u.(layout.SplitPaneLayout)
@@ -171,7 +211,25 @@ func (p *chatPage) GetSize() (int, int) {
} }
func (p *chatPage) View() string { func (p *chatPage) View() string {
return p.layout.View() layoutView := p.layout.View()
if p.showCompletionDialog {
_, layoutHeight := p.layout.GetSize()
editorWidth, editorHeight := p.editor.GetSize()
p.completionDialog.SetWidth(editorWidth)
overlay := p.completionDialog.View()
layoutView = layout.PlaceOverlay(
0,
layoutHeight-editorHeight-lipgloss.Height(overlay),
overlay,
layoutView,
false,
)
}
return layoutView
} }
func (p *chatPage) BindingKeys() []key.Binding { func (p *chatPage) BindingKeys() []key.Binding {
@@ -182,6 +240,8 @@ func (p *chatPage) BindingKeys() []key.Binding {
} }
func NewChatPage(app *app.App) tea.Model { func NewChatPage(app *app.App) tea.Model {
cg := completions.NewFileAndFolderContextGroup()
completionDialog := dialog.NewCompletionDialogCmp(cg)
messagesContainer := layout.NewContainer( messagesContainer := layout.NewContainer(
chat.NewMessagesCmp(app), chat.NewMessagesCmp(app),
layout.WithPadding(1, 1, 0, 1), layout.WithPadding(1, 1, 0, 1),
@@ -191,9 +251,10 @@ func NewChatPage(app *app.App) tea.Model {
layout.WithBorder(true, false, false, false), layout.WithBorder(true, false, false, false),
) )
return &chatPage{ return &chatPage{
app: app, app: app,
editor: editorContainer, editor: editorContainer,
messages: messagesContainer, messages: messagesContainer,
completionDialog: completionDialog,
layout: layout.NewSplitPane( layout: layout.NewSplitPane(
layout.WithLeftPanel(messagesContainer), layout.WithLeftPanel(messagesContainer),
layout.WithBottomPanel(editorContainer), layout.WithBottomPanel(editorContainer),
-13
View File
@@ -10,16 +10,3 @@ const (
SpinnerIcon string = "⟳" SpinnerIcon string = "⟳"
DocumentIcon string = "🖼" DocumentIcon string = "🖼"
) )
// CircledDigit returns the Unicode circled digit/number for 020.
// outofrange → "".
func CircledDigit(n int) string {
switch {
case n == 0:
return "\u24EA" // ⓪
case 1 <= n && n <= 20:
return string(rune(0x2460 + n - 1)) // ①–⑳
default:
return ""
}
}
+48
View File
@@ -202,6 +202,54 @@ func LoadCustomTheme(customTheme map[string]any) (Theme, error) {
theme.DiffAddedLineNumberBgColor = adaptiveColor theme.DiffAddedLineNumberBgColor = adaptiveColor
case "diffremovedlinenumberbg": case "diffremovedlinenumberbg":
theme.DiffRemovedLineNumberBgColor = adaptiveColor 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: default:
slog.Warn("Unknown color key in custom theme", "key", key) slog.Warn("Unknown color key in custom theme", "key", key)
} }
+27 -2
View File
@@ -235,7 +235,19 @@ func ParseAdaptiveColor(value any) (lipgloss.AdaptiveColor, error) {
}, nil }, 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 { if colorMap, ok := value.(map[string]any); ok {
darkVal, darkOk := colorMap["dark"] darkVal, darkOk := colorMap["dark"]
lightVal, lightOk := colorMap["light"] lightVal, lightOk := colorMap["light"]
@@ -248,7 +260,20 @@ func ParseAdaptiveColor(value any) (lipgloss.AdaptiveColor, error) {
lightHex, lightIsString := lightVal.(string) lightHex, lightIsString := lightVal.(string)
if !darkIsString || !lightIsString { 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) { if !hexColorRegex.MatchString(darkHex) || !hexColorRegex.MatchString(lightHex) {
+160 -27
View File
@@ -13,6 +13,7 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/config" "github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/agent"
"github.com/sst/opencode/internal/logging" "github.com/sst/opencode/internal/logging"
"github.com/sst/opencode/internal/message" "github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/permission" "github.com/sst/opencode/internal/permission"
@@ -38,6 +39,7 @@ type keyMap struct {
Filepicker key.Binding Filepicker key.Binding
Models key.Binding Models key.Binding
SwitchTheme key.Binding SwitchTheme key.Binding
Tools key.Binding
} }
const ( const (
@@ -81,6 +83,11 @@ var keys = keyMap{
key.WithKeys("ctrl+t"), key.WithKeys("ctrl+t"),
key.WithHelp("ctrl+t", "switch theme"), key.WithHelp("ctrl+t", "switch theme"),
), ),
Tools: key.NewBinding(
key.WithKeys("f9"),
key.WithHelp("f9", "show available tools"),
),
} }
var helpEsc = key.NewBinding( var helpEsc = key.NewBinding(
@@ -135,8 +142,11 @@ type appModel struct {
showThemeDialog bool showThemeDialog bool
themeDialog dialog.ThemeDialog themeDialog dialog.ThemeDialog
showArgumentsDialog bool showMultiArgumentsDialog bool
argumentsDialog dialog.ArgumentsDialogCmp multiArgumentsDialog dialog.MultiArgumentsDialogCmp
showToolsDialog bool
toolsDialog dialog.ToolsDialog
} }
func (a appModel) Init() tea.Cmd { func (a appModel) Init() tea.Cmd {
@@ -162,6 +172,8 @@ func (a appModel) Init() tea.Cmd {
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
cmd = a.themeDialog.Init() cmd = a.themeDialog.Init()
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
cmd = a.toolsDialog.Init()
cmds = append(cmds, cmd)
// Check if we should show the init dialog // Check if we should show the init dialog
cmds = append(cmds, func() tea.Msg { 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) return a.updateAllPages(msg)
case tea.WindowSizeMsg: 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 a.width, a.height = msg.Width, msg.Height
s, _ := a.status.Update(msg) 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) a.initDialog.SetSize(msg.Width, msg.Height)
if a.showArgumentsDialog { if a.showMultiArgumentsDialog {
a.argumentsDialog.SetSize(msg.Width, msg.Height) a.multiArgumentsDialog.SetSize(msg.Width, msg.Height)
args, argsCmd := a.argumentsDialog.Update(msg) args, argsCmd := a.multiArgumentsDialog.Update(msg)
a.argumentsDialog = args.(dialog.ArgumentsDialogCmp) a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
cmds = append(cmds, argsCmd, a.argumentsDialog.Init()) cmds = append(cmds, argsCmd, a.multiArgumentsDialog.Init())
} }
return a, tea.Batch(cmds...) return a, tea.Batch(cmds...)
@@ -287,6 +299,14 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case dialog.CloseThemeDialogMsg: case dialog.CloseThemeDialogMsg:
a.showThemeDialog = false a.showThemeDialog = false
return a, nil 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: case dialog.ThemeChangedMsg:
a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg) 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) status.Info("Command selected: " + msg.Command.Title)
return a, nil return a, nil
case dialog.ShowArgumentsDialogMsg: case dialog.ShowMultiArgumentsDialogMsg:
// Show arguments dialog // Show multi-arguments dialog
a.argumentsDialog = dialog.NewArgumentsDialogCmp(msg.CommandID, msg.Content) a.multiArgumentsDialog = dialog.NewMultiArgumentsDialogCmp(msg.CommandID, msg.Content, msg.ArgNames)
a.showArgumentsDialog = true a.showMultiArgumentsDialog = true
return a, a.argumentsDialog.Init() return a, a.multiArgumentsDialog.Init()
case dialog.CloseArgumentsDialogMsg: case dialog.CloseMultiArgumentsDialogMsg:
// Close arguments dialog // Close multi-arguments dialog
a.showArgumentsDialog = false a.showMultiArgumentsDialog = false
// If submitted, replace $ARGUMENTS and run the command // If submitted, replace all named arguments and run the command
if msg.Submit { if msg.Submit {
// Replace $ARGUMENTS with the provided arguments content := msg.Content
content := strings.ReplaceAll(msg.Content, "$ARGUMENTS", msg.Arguments)
// 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 // Execute the command with arguments
return a, util.CmdHandler(dialog.CommandRunCustomMsg{ return a, util.CmdHandler(dialog.CommandRunCustomMsg{
Content: content, Content: content,
Args: msg.Args,
}) })
} }
return a, nil return a, nil
case tea.KeyMsg: case tea.KeyMsg:
// If arguments dialog is open, let it handle the key press first // If multi-arguments dialog is open, let it handle the key press first
if a.showArgumentsDialog { if a.showMultiArgumentsDialog {
args, cmd := a.argumentsDialog.Update(msg) args, cmd := a.multiArgumentsDialog.Update(msg)
a.argumentsDialog = args.(dialog.ArgumentsDialogCmp) a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
return a, cmd return a, cmd
} }
@@ -391,16 +417,26 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.showFilepicker { if a.showFilepicker {
a.showFilepicker = false a.showFilepicker = false
a.filepicker.ToggleFilepicker(a.showFilepicker) a.filepicker.ToggleFilepicker(a.showFilepicker)
a.app.SetFilepickerOpen(a.showFilepicker)
} }
if a.showModelDialog { if a.showModelDialog {
a.showModelDialog = false a.showModelDialog = false
} }
if a.showArgumentsDialog { if a.showMultiArgumentsDialog {
a.showArgumentsDialog = false a.showMultiArgumentsDialog = false
}
if a.showToolsDialog {
a.showToolsDialog = false
} }
return a, nil return a, nil
case key.Matches(msg, keys.SwitchSession): case key.Matches(msg, keys.SwitchSession):
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog { 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 // Load sessions and show the dialog
sessions, err := a.app.Sessions.List(context.Background()) sessions, err := a.app.Sessions.List(context.Background())
if err != nil { if err != nil {
@@ -418,6 +454,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, nil return a, nil
case key.Matches(msg, keys.Commands): case key.Matches(msg, keys.Commands):
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog && !a.showFilepicker { 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 // Show commands dialog
if len(a.commands) == 0 { if len(a.commands) == 0 {
status.Warn("No commands available") status.Warn("No commands available")
@@ -434,22 +474,52 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, nil return a, nil
} }
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog { 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 a.showModelDialog = true
return a, nil return a, nil
} }
return a, nil return a, nil
case key.Matches(msg, keys.SwitchTheme): case key.Matches(msg, keys.SwitchTheme):
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog { 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 a.showThemeDialog = true
return a, a.themeDialog.Init() return a, a.themeDialog.Init()
} }
return a, nil 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): case key.Matches(msg, returnKey) || key.Matches(msg):
if msg.String() == quitKey { if msg.String() == quitKey {
if a.currentPage == page.LogsPage { if a.currentPage == page.LogsPage {
return a, a.moveToPage(page.ChatPage) return a, a.moveToPage(page.ChatPage)
} }
} else if !a.filepicker.IsCWDFocused() { } else if !a.filepicker.IsCWDFocused() {
if a.showToolsDialog {
a.showToolsDialog = false
return a, nil
}
if a.showQuit { if a.showQuit {
a.showQuit = !a.showQuit a.showQuit = !a.showQuit
return a, nil return a, nil
@@ -470,6 +540,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.showFilepicker { if a.showFilepicker {
a.showFilepicker = false a.showFilepicker = false
a.filepicker.ToggleFilepicker(a.showFilepicker) a.filepicker.ToggleFilepicker(a.showFilepicker)
a.app.SetFilepickerOpen(a.showFilepicker)
return a, nil return a, nil
} }
if a.currentPage == page.LogsPage { if a.currentPage == page.LogsPage {
@@ -484,6 +555,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, nil return a, nil
} }
a.showHelp = !a.showHelp a.showHelp = !a.showHelp
// Close other dialogs if opening help
if a.showHelp {
a.showToolsDialog = false
}
return a, nil return a, nil
case key.Matches(msg, helpEsc): case key.Matches(msg, helpEsc):
if a.app.PrimaryAgent.IsBusy() { if a.app.PrimaryAgent.IsBusy() {
@@ -494,8 +570,19 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, nil return a, nil
} }
case key.Matches(msg, keys.Filepicker): case key.Matches(msg, keys.Filepicker):
// Toggle filepicker
a.showFilepicker = !a.showFilepicker a.showFilepicker = !a.showFilepicker
a.filepicker.ToggleFilepicker(a.showFilepicker) a.filepicker.ToggleFilepicker(a.showFilepicker)
a.app.SetFilepickerOpen(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 return a, nil
} }
@@ -594,6 +681,16 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, tea.Batch(cmds...) 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) s, cmd := a.status.Update(msg)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
@@ -609,6 +706,26 @@ func (a *appModel) RegisterCommand(cmd dialog.Command) {
a.commands = append(a.commands, cmd) 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 { func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
// Allow navigating to logs page even when agent is busy // Allow navigating to logs page even when agent is busy
if a.app.PrimaryAgent.IsBusy() && pageID != page.LogsPage { if a.app.PrimaryAgent.IsBusy() && pageID != page.LogsPage {
@@ -800,8 +917,23 @@ func (a appModel) View() string {
) )
} }
if a.showArgumentsDialog { if a.showMultiArgumentsDialog {
overlay := a.argumentsDialog.View() 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(appView) / 2
row -= lipgloss.Height(overlay) / 2 row -= lipgloss.Height(overlay) / 2
col := lipgloss.Width(appView) / 2 col := lipgloss.Width(appView) / 2
@@ -832,6 +964,7 @@ func New(app *app.App) tea.Model {
permissions: dialog.NewPermissionDialogCmp(), permissions: dialog.NewPermissionDialogCmp(),
initDialog: dialog.NewInitDialogCmp(), initDialog: dialog.NewInitDialogCmp(),
themeDialog: dialog.NewThemeDialogCmp(), themeDialog: dialog.NewThemeDialogCmp(),
toolsDialog: dialog.NewToolsDialogCmp(),
app: app, app: app,
commands: []dialog.Command{}, commands: []dialog.Command{},
pages: map[page.PageID]tea.Model{ pages: map[page.PageID]tea.Model{
+128 -105
View File
@@ -12,63 +12,74 @@
"model": { "model": {
"description": "Model ID for the agent", "description": "Model ID for the agent",
"enum": [ "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", "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",
"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.gpt-4.1",
"openrouter.o1-pro", "meta-llama/llama-4-scout-17b-16e-instruct",
"gpt-4.1-nano", "openrouter.gpt-4o",
"azure.gpt-4.5-preview", "o1-pro",
"openrouter.claude-3-opus", "claude-3-haiku",
"gpt-4o-mini",
"o1", "o1",
"deepseek-r1-distill-llama-70b", "gemini-2.5-flash",
"azure.gpt-4.1", "vertexai.gemini-2.5-flash",
"gpt-4o",
"azure.gpt-4.1-mini",
"openrouter.claude-3.7-sonnet",
"claude-3.5-haiku", "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" "type": "string"
}, },
@@ -102,63 +113,74 @@
"model": { "model": {
"description": "Model ID for the agent", "description": "Model ID for the agent",
"enum": [ "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", "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",
"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.gpt-4.1",
"openrouter.o1-pro", "meta-llama/llama-4-scout-17b-16e-instruct",
"gpt-4.1-nano", "openrouter.gpt-4o",
"azure.gpt-4.5-preview", "o1-pro",
"openrouter.claude-3-opus", "claude-3-haiku",
"gpt-4o-mini",
"o1", "o1",
"deepseek-r1-distill-llama-70b", "gemini-2.5-flash",
"azure.gpt-4.1", "vertexai.gemini-2.5-flash",
"gpt-4o",
"azure.gpt-4.1-mini",
"openrouter.claude-3.7-sonnet",
"claude-3.5-haiku", "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" "type": "string"
}, },
@@ -341,7 +363,8 @@
"groq", "groq",
"openrouter", "openrouter",
"bedrock", "bedrock",
"azure" "azure",
"vertexai"
], ],
"type": "string" "type": "string"
} }
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB