Compare commits

...

8 Commits

Author SHA1 Message Date
Dax Raad 2b258b1473 switch default to claude sonnet 4 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
19 changed files with 920 additions and 380 deletions
+31
View File
@@ -198,6 +198,37 @@ OpenCode supports a variety of AI models from different providers:
- 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
+1 -1
View File
@@ -26,7 +26,7 @@ require (
github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.16.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/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
github.com/spf13/cobra v1.9.1
+3 -6
View File
@@ -68,8 +68,6 @@ 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/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
@@ -86,9 +84,8 @@ github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2ll
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/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/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -182,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/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
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.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
github.com/openai/openai-go v0.1.0-beta.10 h1:CknhGXe8aXQMRuqg255PFnWzgRY9nEryMxoNIBBM9tU=
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/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+13
View File
@@ -40,6 +40,9 @@ type App struct {
watcherCancelFuncs []context.CancelFunc
cancelFuncsMutex sync.Mutex
watcherWG sync.WaitGroup
// UI state
filepickerOpen bool
}
func New(ctx context.Context, conn *sql.DB) (*App, error) {
@@ -128,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
func (app *App) Shutdown() {
// Cancel all watcher goroutines
+4 -4
View File
@@ -246,9 +246,9 @@ func setProviderDefaults() {
// Anthropic configuration
if key := viper.GetString("providers.anthropic.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.primary.model", models.Claude37Sonnet)
viper.SetDefault("agents.task.model", models.Claude37Sonnet)
viper.SetDefault("agents.title.model", models.Claude37Sonnet)
viper.SetDefault("agents.primary.model", models.Claude4Sonnet)
viper.SetDefault("agents.task.model", models.Claude4Sonnet)
viper.SetDefault("agents.title.model", models.Claude4Sonnet)
return
}
@@ -596,7 +596,7 @@ func setDefaultModelForAgent(agent AgentName) bool {
maxTokens = 80
}
cfg.Agents[agent] = Agent{
Model: models.Claude37Sonnet,
Model: models.Claude4Sonnet,
MaxTokens: maxTokens,
}
return true
+1 -11
View File
@@ -4,12 +4,11 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"log/slog"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/models"
"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)
_, err := a.messages.Update(ctx, *assistantMsg)
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:
assistantMsg.FinishToolCall(event.ToolCall.ID)
_, err := a.messages.Update(ctx, *assistantMsg)
+15
View File
@@ -9,6 +9,7 @@ const (
Claude37Sonnet ModelID = "claude-3.7-sonnet"
Claude35Haiku ModelID = "claude-3.5-haiku"
Claude3Opus ModelID = "claude-3-opus"
Claude4Sonnet ModelID = "claude-4-sonnet"
)
// https://docs.anthropic.com/en/docs/about-claude/models/all-models
@@ -53,6 +54,20 @@ var AnthropicModels = map[ModelID]Model{
CanReason: 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: {
ID: Claude35Haiku,
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,
CostPer1MOutCached: 0,
CostPer1MOut: 0.34,
DefaultMaxTokens: 8192,
ContextWindow: 128_000, // 10M when?
SupportsAttachments: true,
},
@@ -54,6 +55,7 @@ var GroqModels = map[ModelID]Model{
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 0.20,
DefaultMaxTokens: 8192,
ContextWindow: 128_000,
SupportsAttachments: true,
},
+2 -48
View File
@@ -22,14 +22,7 @@ type Model struct {
SupportsAttachments bool `json:"supports_attachments"`
}
// Model IDs
const ( // GEMINI
// Bedrock
BedrockClaude37Sonnet ModelID = "bedrock.claude-3.7-sonnet"
)
const (
ProviderBedrock ModelProvider = "bedrock"
// ForTests
ProviderMock ModelProvider = "__mock"
)
@@ -46,50 +39,11 @@ var ProviderPopularity = map[ModelProvider]int{
ProviderVertexAI: 8,
}
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,
},
}
var SupportedModels = map[ModelID]Model{}
func init() {
maps.Copy(SupportedModels, AnthropicModels)
maps.Copy(SupportedModels, BedrockModels)
maps.Copy(SupportedModels, OpenAIModels)
maps.Copy(SupportedModels, GeminiModels)
maps.Copy(SupportedModels, GroqModels)
+15
View File
@@ -3,6 +3,7 @@ package models
const (
ProviderOpenAI ModelProvider = "openai"
CodexMini ModelID = "codex-mini"
GPT41 ModelID = "gpt-4.1"
GPT41Mini ModelID = "gpt-4.1-mini"
GPT41Nano ModelID = "gpt-4.1-nano"
@@ -18,6 +19,20 @@ const (
)
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: {
ID: GPT41,
Name: "GPT 4.1",
+10 -297
View File
@@ -2,21 +2,14 @@ package provider
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"time"
"log/slog"
"github.com/openai/openai-go"
"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/tools"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/status"
"log/slog"
)
type openaiOptions struct {
@@ -66,87 +59,21 @@ func newOpenAIClient(opts providerClientOptions) OpenAIClient {
}
}
func (o *openaiClient) convertMessages(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),
)
}
}
func (o *openaiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
if o.providerOptions.model.ID == models.OpenAIModels[models.CodexMini].ID || o.providerOptions.model.ID == models.OpenAIModels[models.O1Pro].ID {
return o.sendResponseMessages(ctx, messages, tools)
}
return
return o.sendChatcompletionMessage(ctx, messages, tools)
}
func (o *openaiClient) convertTools(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,
},
},
}
func (o *openaiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
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)
}
return openaiTools
return o.streamChatCompletionMessages(ctx, messages, tools)
}
func (o *openaiClient) finishReason(reason string) message.FinishReason {
switch reason {
case "stop":
@@ -160,190 +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)
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.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)
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) shouldRetry(attempts int, err error) (bool, int64, error) {
var apierr *openai.Error
@@ -373,36 +116,6 @@ func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error)
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 {
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,
}
}
+1 -1
View File
@@ -3,11 +3,11 @@ package provider
import (
"context"
"fmt"
"log/slog"
"github.com/sst/opencode/internal/llm/models"
"github.com/sst/opencode/internal/llm/tools"
"github.com/sst/opencode/internal/message"
"log/slog"
)
type EventType string
+79 -9
View File
@@ -25,18 +25,23 @@ import (
)
type editorCmp struct {
width int
height int
app *app.App
textarea textarea.Model
attachments []message.Attachment
deleteMode bool
width int
height int
app *app.App
textarea textarea.Model
attachments []message.Attachment
deleteMode bool
history []string
historyIndex int
currentMessage string
}
type EditorKeyMaps struct {
Send key.Binding
OpenEditor key.Binding
Paste key.Binding
HistoryUp key.Binding
HistoryDown key.Binding
}
type bluredEditorKeyMaps struct {
@@ -63,6 +68,14 @@ var editorMaps = EditorKeyMaps{
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{
@@ -76,7 +89,7 @@ var DeleteKeyMaps = DeleteAttachmentKeyMaps{
),
DeleteAllAttachments: key.NewBinding(
key.WithKeys("r"),
key.WithHelp("ctrl+r+r", "delete all attchments"),
key.WithHelp("ctrl+r+r", "delete all attachments"),
),
}
@@ -139,6 +152,15 @@ func (m *editorCmp) send() tea.Cmd {
m.textarea.Reset()
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
if value == "" {
return nil
@@ -223,6 +245,51 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
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
if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
value := m.textarea.Value()
@@ -336,7 +403,10 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
func NewEditorCmp(app *app.App) tea.Model {
ta := CreateTextArea(nil)
return &editorCmp{
app: app,
textarea: ta,
app: app,
textarea: ta,
history: []string{},
historyIndex: 0,
currentMessage: "",
}
}
+2
View File
@@ -386,6 +386,8 @@ func (m *messagesCmp) help() string {
baseStyle.Foreground(t.TextMuted()).Bold(true).Render("+"),
baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
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.TextMuted()).Bold(true).Render(" to toggle tool messages"),
)
+3 -3
View File
@@ -84,7 +84,7 @@ func (i *detailCmp) updateContent() {
messageStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Text())
content.WriteString(messageStyle.Render("Message:"))
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")
// Attributes section
@@ -112,7 +112,7 @@ func (i *detailCmp) updateContent() {
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")
}
}
@@ -123,7 +123,7 @@ func (i *detailCmp) updateContent() {
content.WriteString("\n")
content.WriteString(sessionStyle.Render("Session:"))
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())
+3
View File
@@ -417,6 +417,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.showFilepicker {
a.showFilepicker = false
a.filepicker.ToggleFilepicker(a.showFilepicker)
a.app.SetFilepickerOpen(a.showFilepicker)
}
if a.showModelDialog {
a.showModelDialog = false
@@ -539,6 +540,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.showFilepicker {
a.showFilepicker = false
a.filepicker.ToggleFilepicker(a.showFilepicker)
a.app.SetFilepickerOpen(a.showFilepicker)
return a, nil
}
if a.currentPage == page.LogsPage {
@@ -571,6 +573,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Toggle filepicker
a.showFilepicker = !a.showFilepicker
a.filepicker.ToggleFilepicker(a.showFilepicker)
a.app.SetFilepickerOpen(a.showFilepicker)
// Close other dialogs if opening filepicker
if a.showFilepicker {