mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-17 12:56:41 +02:00
Context Window Warning (#152)
* context window warning & compact command * auto compact * fix permissions * update readme * fix 3.5 context window * small update * remove unused interface * remove unused msg
This commit is contained in:
@@ -36,9 +36,10 @@ type MCPServer struct {
|
||||
type AgentName string
|
||||
|
||||
const (
|
||||
AgentCoder AgentName = "coder"
|
||||
AgentTask AgentName = "task"
|
||||
AgentTitle AgentName = "title"
|
||||
AgentCoder AgentName = "coder"
|
||||
AgentSummarizer AgentName = "summarizer"
|
||||
AgentTask AgentName = "task"
|
||||
AgentTitle AgentName = "title"
|
||||
)
|
||||
|
||||
// Agent defines configuration for different LLM models and their token limits.
|
||||
@@ -84,6 +85,7 @@ type Config struct {
|
||||
DebugLSP bool `json:"debugLSP,omitempty"`
|
||||
ContextPaths []string `json:"contextPaths,omitempty"`
|
||||
TUI TUIConfig `json:"tui"`
|
||||
AutoCompact bool `json:"autoCompact,omitempty"`
|
||||
}
|
||||
|
||||
// Application constants
|
||||
@@ -213,6 +215,7 @@ func setDefaults(debug bool) {
|
||||
viper.SetDefault("data.directory", defaultDataDirectory)
|
||||
viper.SetDefault("contextPaths", defaultContextPaths)
|
||||
viper.SetDefault("tui.theme", "opencode")
|
||||
viper.SetDefault("autoCompact", true)
|
||||
|
||||
if debug {
|
||||
viper.SetDefault("debug", true)
|
||||
@@ -262,6 +265,7 @@ func setProviderDefaults() {
|
||||
// Anthropic configuration
|
||||
if key := viper.GetString("providers.anthropic.apiKey"); strings.TrimSpace(key) != "" {
|
||||
viper.SetDefault("agents.coder.model", models.Claude37Sonnet)
|
||||
viper.SetDefault("agents.summarizer.model", models.Claude37Sonnet)
|
||||
viper.SetDefault("agents.task.model", models.Claude37Sonnet)
|
||||
viper.SetDefault("agents.title.model", models.Claude37Sonnet)
|
||||
return
|
||||
@@ -270,6 +274,7 @@ func setProviderDefaults() {
|
||||
// OpenAI configuration
|
||||
if key := viper.GetString("providers.openai.apiKey"); strings.TrimSpace(key) != "" {
|
||||
viper.SetDefault("agents.coder.model", models.GPT41)
|
||||
viper.SetDefault("agents.summarizer.model", models.GPT41)
|
||||
viper.SetDefault("agents.task.model", models.GPT41Mini)
|
||||
viper.SetDefault("agents.title.model", models.GPT41Mini)
|
||||
return
|
||||
@@ -278,6 +283,7 @@ func setProviderDefaults() {
|
||||
// Google Gemini configuration
|
||||
if key := viper.GetString("providers.gemini.apiKey"); strings.TrimSpace(key) != "" {
|
||||
viper.SetDefault("agents.coder.model", models.Gemini25)
|
||||
viper.SetDefault("agents.summarizer.model", models.Gemini25)
|
||||
viper.SetDefault("agents.task.model", models.Gemini25Flash)
|
||||
viper.SetDefault("agents.title.model", models.Gemini25Flash)
|
||||
return
|
||||
@@ -286,6 +292,7 @@ func setProviderDefaults() {
|
||||
// Groq configuration
|
||||
if key := viper.GetString("providers.groq.apiKey"); strings.TrimSpace(key) != "" {
|
||||
viper.SetDefault("agents.coder.model", models.QWENQwq)
|
||||
viper.SetDefault("agents.summarizer.model", models.QWENQwq)
|
||||
viper.SetDefault("agents.task.model", models.QWENQwq)
|
||||
viper.SetDefault("agents.title.model", models.QWENQwq)
|
||||
return
|
||||
@@ -294,6 +301,7 @@ func setProviderDefaults() {
|
||||
// OpenRouter configuration
|
||||
if key := viper.GetString("providers.openrouter.apiKey"); strings.TrimSpace(key) != "" {
|
||||
viper.SetDefault("agents.coder.model", models.OpenRouterClaude37Sonnet)
|
||||
viper.SetDefault("agents.summarizer.model", models.OpenRouterClaude37Sonnet)
|
||||
viper.SetDefault("agents.task.model", models.OpenRouterClaude37Sonnet)
|
||||
viper.SetDefault("agents.title.model", models.OpenRouterClaude35Haiku)
|
||||
return
|
||||
@@ -302,6 +310,7 @@ func setProviderDefaults() {
|
||||
// XAI configuration
|
||||
if key := viper.GetString("providers.xai.apiKey"); strings.TrimSpace(key) != "" {
|
||||
viper.SetDefault("agents.coder.model", models.XAIGrok3Beta)
|
||||
viper.SetDefault("agents.summarizer.model", models.XAIGrok3Beta)
|
||||
viper.SetDefault("agents.task.model", models.XAIGrok3Beta)
|
||||
viper.SetDefault("agents.title.model", models.XAiGrok3MiniFastBeta)
|
||||
return
|
||||
@@ -310,6 +319,7 @@ func setProviderDefaults() {
|
||||
// AWS Bedrock configuration
|
||||
if hasAWSCredentials() {
|
||||
viper.SetDefault("agents.coder.model", models.BedrockClaude37Sonnet)
|
||||
viper.SetDefault("agents.summarizer.model", models.BedrockClaude37Sonnet)
|
||||
viper.SetDefault("agents.task.model", models.BedrockClaude37Sonnet)
|
||||
viper.SetDefault("agents.title.model", models.BedrockClaude37Sonnet)
|
||||
return
|
||||
@@ -318,6 +328,7 @@ func setProviderDefaults() {
|
||||
// Azure OpenAI configuration
|
||||
if os.Getenv("AZURE_OPENAI_ENDPOINT") != "" {
|
||||
viper.SetDefault("agents.coder.model", models.AzureGPT41)
|
||||
viper.SetDefault("agents.summarizer.model", models.AzureGPT41)
|
||||
viper.SetDefault("agents.task.model", models.AzureGPT41Mini)
|
||||
viper.SetDefault("agents.title.model", models.AzureGPT41Mini)
|
||||
return
|
||||
|
||||
@@ -69,11 +69,11 @@ func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolRes
|
||||
return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", err)
|
||||
}
|
||||
result := <-done
|
||||
if result.Err() != nil {
|
||||
return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", result.Err())
|
||||
if result.Error != nil {
|
||||
return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", result.Error)
|
||||
}
|
||||
|
||||
response := result.Response()
|
||||
response := result.Message
|
||||
if response.Role != message.Assistant {
|
||||
return tools.NewTextErrorResponse("no response"), nil
|
||||
}
|
||||
@@ -88,8 +88,6 @@ func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolRes
|
||||
}
|
||||
|
||||
parentSession.Cost += updatedSession.Cost
|
||||
parentSession.PromptTokens += updatedSession.PromptTokens
|
||||
parentSession.CompletionTokens += updatedSession.CompletionTokens
|
||||
|
||||
_, err = b.sessions.Save(ctx, parentSession)
|
||||
if err != nil {
|
||||
|
||||
+216
-22
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/opencode-ai/opencode/internal/logging"
|
||||
"github.com/opencode-ai/opencode/internal/message"
|
||||
"github.com/opencode-ai/opencode/internal/permission"
|
||||
"github.com/opencode-ai/opencode/internal/pubsub"
|
||||
"github.com/opencode-ai/opencode/internal/session"
|
||||
)
|
||||
|
||||
@@ -24,35 +25,46 @@ var (
|
||||
ErrSessionBusy = errors.New("session is currently processing another request")
|
||||
)
|
||||
|
||||
type AgentEventType string
|
||||
|
||||
const (
|
||||
AgentEventTypeError AgentEventType = "error"
|
||||
AgentEventTypeResponse AgentEventType = "response"
|
||||
AgentEventTypeSummarize AgentEventType = "summarize"
|
||||
)
|
||||
|
||||
type AgentEvent struct {
|
||||
message message.Message
|
||||
err error
|
||||
}
|
||||
Type AgentEventType
|
||||
Message message.Message
|
||||
Error error
|
||||
|
||||
func (e *AgentEvent) Err() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
func (e *AgentEvent) Response() message.Message {
|
||||
return e.message
|
||||
// When summarizing
|
||||
SessionID string
|
||||
Progress string
|
||||
Done bool
|
||||
}
|
||||
|
||||
type Service interface {
|
||||
pubsub.Suscriber[AgentEvent]
|
||||
Model() models.Model
|
||||
Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
|
||||
Cancel(sessionID string)
|
||||
IsSessionBusy(sessionID string) bool
|
||||
IsBusy() bool
|
||||
Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error)
|
||||
Summarize(ctx context.Context, sessionID string) error
|
||||
}
|
||||
|
||||
type agent struct {
|
||||
*pubsub.Broker[AgentEvent]
|
||||
sessions session.Service
|
||||
messages message.Service
|
||||
|
||||
tools []tools.BaseTool
|
||||
provider provider.Provider
|
||||
|
||||
titleProvider provider.Provider
|
||||
titleProvider provider.Provider
|
||||
summarizeProvider provider.Provider
|
||||
|
||||
activeRequests sync.Map
|
||||
}
|
||||
@@ -75,26 +87,48 @@ func NewAgent(
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var summarizeProvider provider.Provider
|
||||
if agentName == config.AgentCoder {
|
||||
summarizeProvider, err = createAgentProvider(config.AgentSummarizer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
agent := &agent{
|
||||
provider: agentProvider,
|
||||
messages: messages,
|
||||
sessions: sessions,
|
||||
tools: agentTools,
|
||||
titleProvider: titleProvider,
|
||||
activeRequests: sync.Map{},
|
||||
Broker: pubsub.NewBroker[AgentEvent](),
|
||||
provider: agentProvider,
|
||||
messages: messages,
|
||||
sessions: sessions,
|
||||
tools: agentTools,
|
||||
titleProvider: titleProvider,
|
||||
summarizeProvider: summarizeProvider,
|
||||
activeRequests: sync.Map{},
|
||||
}
|
||||
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
func (a *agent) Model() models.Model {
|
||||
return a.provider.Model()
|
||||
}
|
||||
|
||||
func (a *agent) Cancel(sessionID string) {
|
||||
// Cancel regular requests
|
||||
if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID); exists {
|
||||
if cancel, ok := cancelFunc.(context.CancelFunc); ok {
|
||||
logging.InfoPersist(fmt.Sprintf("Request cancellation initiated for session: %s", sessionID))
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for summarize requests
|
||||
if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID + "-summarize"); exists {
|
||||
if cancel, ok := cancelFunc.(context.CancelFunc); ok {
|
||||
logging.InfoPersist(fmt.Sprintf("Summarize cancellation initiated for session: %s", sessionID))
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *agent) IsBusy() bool {
|
||||
@@ -154,7 +188,8 @@ func (a *agent) generateTitle(ctx context.Context, sessionID string, content str
|
||||
|
||||
func (a *agent) err(err error) AgentEvent {
|
||||
return AgentEvent{
|
||||
err: err,
|
||||
Type: AgentEventTypeError,
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,12 +215,13 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
|
||||
attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
|
||||
}
|
||||
result := a.processGeneration(genCtx, sessionID, content, attachmentParts)
|
||||
if result.Err() != nil && !errors.Is(result.Err(), ErrRequestCancelled) && !errors.Is(result.Err(), context.Canceled) {
|
||||
logging.ErrorPersist(result.Err().Error())
|
||||
if result.Error != nil && !errors.Is(result.Error, ErrRequestCancelled) && !errors.Is(result.Error, context.Canceled) {
|
||||
logging.ErrorPersist(result.Error.Error())
|
||||
}
|
||||
logging.Debug("Request completed", "sessionID", sessionID)
|
||||
a.activeRequests.Delete(sessionID)
|
||||
cancel()
|
||||
a.Publish(pubsub.CreatedEvent, result)
|
||||
events <- result
|
||||
close(events)
|
||||
}()
|
||||
@@ -241,7 +277,9 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
|
||||
continue
|
||||
}
|
||||
return AgentEvent{
|
||||
message: agentMessage,
|
||||
Type: AgentEventTypeResponse,
|
||||
Message: agentMessage,
|
||||
Done: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -432,8 +470,8 @@ func (a *agent) TrackUsage(ctx context.Context, sessionID string, model models.M
|
||||
model.CostPer1MOut/1e6*float64(usage.OutputTokens)
|
||||
|
||||
sess.Cost += cost
|
||||
sess.CompletionTokens += usage.OutputTokens
|
||||
sess.PromptTokens += usage.InputTokens
|
||||
sess.CompletionTokens = usage.OutputTokens + usage.CacheReadTokens
|
||||
sess.PromptTokens = usage.InputTokens + usage.CacheCreationTokens
|
||||
|
||||
_, err = a.sessions.Save(ctx, sess)
|
||||
if err != nil {
|
||||
@@ -461,6 +499,162 @@ func (a *agent) Update(agentName config.AgentName, modelID models.ModelID) (mode
|
||||
return a.provider.Model(), nil
|
||||
}
|
||||
|
||||
func (a *agent) Summarize(ctx context.Context, sessionID string) error {
|
||||
if a.summarizeProvider == nil {
|
||||
return fmt.Errorf("summarize provider not available")
|
||||
}
|
||||
|
||||
// Check if session is busy
|
||||
if a.IsSessionBusy(sessionID) {
|
||||
return ErrSessionBusy
|
||||
}
|
||||
|
||||
// Create a new context with cancellation
|
||||
summarizeCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
// Store the cancel function in activeRequests to allow cancellation
|
||||
a.activeRequests.Store(sessionID+"-summarize", cancel)
|
||||
|
||||
go func() {
|
||||
defer a.activeRequests.Delete(sessionID + "-summarize")
|
||||
defer cancel()
|
||||
event := AgentEvent{
|
||||
Type: AgentEventTypeSummarize,
|
||||
Progress: "Starting summarization...",
|
||||
}
|
||||
|
||||
a.Publish(pubsub.CreatedEvent, event)
|
||||
// Get all messages from the session
|
||||
msgs, err := a.messages.List(summarizeCtx, sessionID)
|
||||
if err != nil {
|
||||
event = AgentEvent{
|
||||
Type: AgentEventTypeError,
|
||||
Error: fmt.Errorf("failed to list messages: %w", err),
|
||||
Done: true,
|
||||
}
|
||||
a.Publish(pubsub.CreatedEvent, event)
|
||||
return
|
||||
}
|
||||
|
||||
if len(msgs) == 0 {
|
||||
event = AgentEvent{
|
||||
Type: AgentEventTypeError,
|
||||
Error: fmt.Errorf("no messages to summarize"),
|
||||
Done: true,
|
||||
}
|
||||
a.Publish(pubsub.CreatedEvent, event)
|
||||
return
|
||||
}
|
||||
|
||||
event = AgentEvent{
|
||||
Type: AgentEventTypeSummarize,
|
||||
Progress: "Analyzing conversation...",
|
||||
}
|
||||
a.Publish(pubsub.CreatedEvent, event)
|
||||
|
||||
// Add a system message to guide the summarization
|
||||
summarizePrompt := "Provide a detailed but concise summary of our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next."
|
||||
|
||||
// Create a new message with the summarize prompt
|
||||
promptMsg := message.Message{
|
||||
Role: message.User,
|
||||
Parts: []message.ContentPart{message.TextContent{Text: summarizePrompt}},
|
||||
}
|
||||
|
||||
// Append the prompt to the messages
|
||||
msgsWithPrompt := append(msgs, promptMsg)
|
||||
|
||||
event = AgentEvent{
|
||||
Type: AgentEventTypeSummarize,
|
||||
Progress: "Generating summary...",
|
||||
}
|
||||
|
||||
a.Publish(pubsub.CreatedEvent, event)
|
||||
|
||||
// Send the messages to the summarize provider
|
||||
response, err := a.summarizeProvider.SendMessages(
|
||||
summarizeCtx,
|
||||
msgsWithPrompt,
|
||||
make([]tools.BaseTool, 0),
|
||||
)
|
||||
if err != nil {
|
||||
event = AgentEvent{
|
||||
Type: AgentEventTypeError,
|
||||
Error: fmt.Errorf("failed to summarize: %w", err),
|
||||
Done: true,
|
||||
}
|
||||
a.Publish(pubsub.CreatedEvent, event)
|
||||
return
|
||||
}
|
||||
|
||||
summary := strings.TrimSpace(response.Content)
|
||||
if summary == "" {
|
||||
event = AgentEvent{
|
||||
Type: AgentEventTypeError,
|
||||
Error: fmt.Errorf("empty summary returned"),
|
||||
Done: true,
|
||||
}
|
||||
a.Publish(pubsub.CreatedEvent, event)
|
||||
return
|
||||
}
|
||||
event = AgentEvent{
|
||||
Type: AgentEventTypeSummarize,
|
||||
Progress: "Creating new session...",
|
||||
}
|
||||
|
||||
a.Publish(pubsub.CreatedEvent, event)
|
||||
oldSession, err := a.sessions.Get(summarizeCtx, sessionID)
|
||||
if err != nil {
|
||||
event = AgentEvent{
|
||||
Type: AgentEventTypeError,
|
||||
Error: fmt.Errorf("failed to get session: %w", err),
|
||||
Done: true,
|
||||
}
|
||||
|
||||
a.Publish(pubsub.CreatedEvent, event)
|
||||
return
|
||||
}
|
||||
// Create a new session with the summary
|
||||
newSession, err := a.sessions.Create(summarizeCtx, oldSession.Title+" - Continuation")
|
||||
if err != nil {
|
||||
event = AgentEvent{
|
||||
Type: AgentEventTypeError,
|
||||
Error: fmt.Errorf("failed to create new session: %w", err),
|
||||
Done: true,
|
||||
}
|
||||
a.Publish(pubsub.CreatedEvent, event)
|
||||
return
|
||||
}
|
||||
|
||||
// Create a message in the new session with the summary
|
||||
_, err = a.messages.Create(summarizeCtx, newSession.ID, message.CreateMessageParams{
|
||||
Role: message.Assistant,
|
||||
Parts: []message.ContentPart{message.TextContent{Text: summary}},
|
||||
Model: a.summarizeProvider.Model().ID,
|
||||
})
|
||||
if err != nil {
|
||||
event = AgentEvent{
|
||||
Type: AgentEventTypeError,
|
||||
Error: fmt.Errorf("failed to create summary message: %w", err),
|
||||
Done: true,
|
||||
}
|
||||
|
||||
a.Publish(pubsub.CreatedEvent, event)
|
||||
return
|
||||
}
|
||||
event = AgentEvent{
|
||||
Type: AgentEventTypeSummarize,
|
||||
SessionID: newSession.ID,
|
||||
Progress: "Summary complete",
|
||||
Done: true,
|
||||
}
|
||||
a.Publish(pubsub.CreatedEvent, event)
|
||||
// Send final success event with the new session ID
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createAgentProvider(agentName config.AgentName) (provider.Provider, error) {
|
||||
cfg := config.Get()
|
||||
agentConfig, ok := cfg.Agents[agentName]
|
||||
|
||||
@@ -21,6 +21,8 @@ func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) s
|
||||
basePrompt = TitlePrompt(provider)
|
||||
case config.AgentTask:
|
||||
basePrompt = TaskPrompt(provider)
|
||||
case config.AgentSummarizer:
|
||||
basePrompt = SummarizerPrompt(provider)
|
||||
default:
|
||||
basePrompt = "You are a helpful assistant"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package prompt
|
||||
|
||||
import "github.com/opencode-ai/opencode/internal/llm/models"
|
||||
|
||||
func SummarizerPrompt(_ models.ModelProvider) string {
|
||||
return `You are a helpful AI assistant tasked with summarizing conversations.
|
||||
|
||||
When asked to summarize, provide a detailed but concise summary of the conversation.
|
||||
Focus on information that would be helpful for continuing the conversation, including:
|
||||
- What was done
|
||||
- What is currently being worked on
|
||||
- Which files are being modified
|
||||
- What needs to be done next
|
||||
|
||||
Your summary should be comprehensive enough to provide context but concise enough to be quickly understood.`
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
|
||||
type StatusCmp interface {
|
||||
tea.Model
|
||||
SetHelpWidgetMsg(string)
|
||||
}
|
||||
|
||||
type statusCmp struct {
|
||||
@@ -74,11 +73,9 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var helpWidget = ""
|
||||
|
||||
// getHelpWidget returns the help widget with current theme colors
|
||||
func getHelpWidget(helpText string) string {
|
||||
func getHelpWidget() string {
|
||||
t := theme.CurrentTheme()
|
||||
if helpText == "" {
|
||||
helpText = "ctrl+? help"
|
||||
}
|
||||
helpText := "ctrl+? help"
|
||||
|
||||
return styles.Padded().
|
||||
Background(t.TextMuted()).
|
||||
@@ -87,7 +84,7 @@ func getHelpWidget(helpText string) string {
|
||||
Render(helpText)
|
||||
}
|
||||
|
||||
func formatTokensAndCost(tokens int64, cost float64) string {
|
||||
func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
|
||||
// Format tokens in human-readable format (e.g., 110K, 1.2M)
|
||||
var formattedTokens string
|
||||
switch {
|
||||
@@ -110,32 +107,48 @@ func formatTokensAndCost(tokens int64, cost float64) string {
|
||||
// Format cost with $ symbol and 2 decimal places
|
||||
formattedCost := fmt.Sprintf("$%.2f", cost)
|
||||
|
||||
return fmt.Sprintf("Tokens: %s, Cost: %s", formattedTokens, formattedCost)
|
||||
percentage := (float64(tokens) / float64(contextWindow)) * 100
|
||||
if percentage > 80 {
|
||||
// add the warning icon and percentage
|
||||
formattedTokens = fmt.Sprintf("%s(%d%%)", styles.WarningIcon, int(percentage))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Context: %s, Cost: %s", formattedTokens, formattedCost)
|
||||
}
|
||||
|
||||
func (m statusCmp) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
modelID := config.Get().Agents[config.AgentCoder].Model
|
||||
model := models.SupportedModels[modelID]
|
||||
|
||||
// Initialize the help widget
|
||||
status := getHelpWidget("")
|
||||
status := getHelpWidget()
|
||||
|
||||
tokenInfoWidth := 0
|
||||
if m.session.ID != "" {
|
||||
tokens := formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
|
||||
totalTokens := m.session.PromptTokens + m.session.CompletionTokens
|
||||
tokens := formatTokensAndCost(totalTokens, model.ContextWindow, m.session.Cost)
|
||||
tokensStyle := styles.Padded().
|
||||
Background(t.Text()).
|
||||
Foreground(t.BackgroundSecondary()).
|
||||
Render(tokens)
|
||||
status += tokensStyle
|
||||
Foreground(t.BackgroundSecondary())
|
||||
percentage := (float64(totalTokens) / float64(model.ContextWindow)) * 100
|
||||
if percentage > 80 {
|
||||
tokensStyle = tokensStyle.Background(t.Warning())
|
||||
}
|
||||
tokenInfoWidth = lipgloss.Width(tokens) + 2
|
||||
status += tokensStyle.Render(tokens)
|
||||
}
|
||||
|
||||
diagnostics := styles.Padded().
|
||||
Background(t.BackgroundDarker()).
|
||||
Render(m.projectDiagnostics())
|
||||
|
||||
availableWidht := max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokenInfoWidth)
|
||||
|
||||
if m.info.Msg != "" {
|
||||
infoStyle := styles.Padded().
|
||||
Foreground(t.Background()).
|
||||
Width(m.availableFooterMsgWidth(diagnostics))
|
||||
Width(availableWidht)
|
||||
|
||||
switch m.info.Type {
|
||||
case util.InfoTypeInfo:
|
||||
@@ -146,18 +159,18 @@ func (m statusCmp) View() string {
|
||||
infoStyle = infoStyle.Background(t.Error())
|
||||
}
|
||||
|
||||
infoWidth := availableWidht - 10
|
||||
// Truncate message if it's longer than available width
|
||||
msg := m.info.Msg
|
||||
availWidth := m.availableFooterMsgWidth(diagnostics) - 10
|
||||
if len(msg) > availWidth && availWidth > 0 {
|
||||
msg = msg[:availWidth] + "..."
|
||||
if len(msg) > infoWidth && infoWidth > 0 {
|
||||
msg = msg[:infoWidth] + "..."
|
||||
}
|
||||
status += infoStyle.Render(msg)
|
||||
} else {
|
||||
status += styles.Padded().
|
||||
Foreground(t.Text()).
|
||||
Background(t.BackgroundSecondary()).
|
||||
Width(m.availableFooterMsgWidth(diagnostics)).
|
||||
Width(availableWidht).
|
||||
Render("")
|
||||
}
|
||||
|
||||
@@ -245,12 +258,10 @@ func (m *statusCmp) projectDiagnostics() string {
|
||||
return strings.Join(diagnostics, " ")
|
||||
}
|
||||
|
||||
func (m statusCmp) availableFooterMsgWidth(diagnostics string) int {
|
||||
tokens := ""
|
||||
func (m statusCmp) availableFooterMsgWidth(diagnostics, tokenInfo string) int {
|
||||
tokensWidth := 0
|
||||
if m.session.ID != "" {
|
||||
tokens = formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
|
||||
tokensWidth = lipgloss.Width(tokens) + 2
|
||||
tokensWidth = lipgloss.Width(tokenInfo) + 2
|
||||
}
|
||||
return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokensWidth)
|
||||
}
|
||||
@@ -272,14 +283,8 @@ func (m statusCmp) model() string {
|
||||
Render(model.Name)
|
||||
}
|
||||
|
||||
func (m statusCmp) SetHelpWidgetMsg(s string) {
|
||||
// Update the help widget text using the getHelpWidget function
|
||||
helpWidget = getHelpWidget(s)
|
||||
}
|
||||
|
||||
func NewStatusCmp(lspClients map[string]*lsp.Client) StatusCmp {
|
||||
// Initialize the help widget with default text
|
||||
helpWidget = getHelpWidget("")
|
||||
helpWidget = getHelpWidget()
|
||||
|
||||
return &statusCmp{
|
||||
messageTTL: 10 * time.Second,
|
||||
|
||||
@@ -302,11 +302,8 @@ func (f *filepickerCmp) View() string {
|
||||
}
|
||||
if file.IsDir() {
|
||||
filename = filename + "/"
|
||||
} else if isExtSupported(file.Name()) {
|
||||
filename = filename
|
||||
} else {
|
||||
filename = filename
|
||||
}
|
||||
// No need to reassign filename if it's not changing
|
||||
|
||||
files = append(files, itemStyle.Padding(0, 1).Render(filename))
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package dialog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@@ -13,7 +15,6 @@ import (
|
||||
"github.com/opencode-ai/opencode/internal/tui/styles"
|
||||
"github.com/opencode-ai/opencode/internal/tui/theme"
|
||||
"github.com/opencode-ai/opencode/internal/tui/util"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type PermissionAction string
|
||||
@@ -150,7 +151,7 @@ func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
|
||||
func (p *permissionDialogCmp) renderButtons() string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.BaseStyle()
|
||||
|
||||
|
||||
allowStyle := baseStyle
|
||||
allowSessionStyle := baseStyle
|
||||
denyStyle := baseStyle
|
||||
@@ -196,7 +197,7 @@ func (p *permissionDialogCmp) renderButtons() string {
|
||||
func (p *permissionDialogCmp) renderHeader() string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.BaseStyle()
|
||||
|
||||
|
||||
toolKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Tool")
|
||||
toolValue := baseStyle.
|
||||
Foreground(t.Text()).
|
||||
@@ -229,9 +230,36 @@ func (p *permissionDialogCmp) renderHeader() string {
|
||||
case tools.BashToolName:
|
||||
headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Command"))
|
||||
case tools.EditToolName:
|
||||
headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Diff"))
|
||||
params := p.permission.Params.(tools.EditPermissionsParams)
|
||||
fileKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("File")
|
||||
filePath := baseStyle.
|
||||
Foreground(t.Text()).
|
||||
Width(p.width - lipgloss.Width(fileKey)).
|
||||
Render(fmt.Sprintf(": %s", params.FilePath))
|
||||
headerParts = append(headerParts,
|
||||
lipgloss.JoinHorizontal(
|
||||
lipgloss.Left,
|
||||
fileKey,
|
||||
filePath,
|
||||
),
|
||||
baseStyle.Render(strings.Repeat(" ", p.width)),
|
||||
)
|
||||
|
||||
case tools.WriteToolName:
|
||||
headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Diff"))
|
||||
params := p.permission.Params.(tools.WritePermissionsParams)
|
||||
fileKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("File")
|
||||
filePath := baseStyle.
|
||||
Foreground(t.Text()).
|
||||
Width(p.width - lipgloss.Width(fileKey)).
|
||||
Render(fmt.Sprintf(": %s", params.FilePath))
|
||||
headerParts = append(headerParts,
|
||||
lipgloss.JoinHorizontal(
|
||||
lipgloss.Left,
|
||||
fileKey,
|
||||
filePath,
|
||||
),
|
||||
baseStyle.Render(strings.Repeat(" ", p.width)),
|
||||
)
|
||||
case tools.FetchToolName:
|
||||
headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("URL"))
|
||||
}
|
||||
@@ -242,13 +270,13 @@ func (p *permissionDialogCmp) renderHeader() string {
|
||||
func (p *permissionDialogCmp) renderBashContent() string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.BaseStyle()
|
||||
|
||||
|
||||
if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
|
||||
content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
|
||||
|
||||
// Use the cache for markdown rendering
|
||||
renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
|
||||
r := styles.GetMarkdownRenderer(p.width-10)
|
||||
r := styles.GetMarkdownRenderer(p.width - 10)
|
||||
s, err := r.Render(content)
|
||||
return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
|
||||
})
|
||||
@@ -302,13 +330,13 @@ func (p *permissionDialogCmp) renderWriteContent() string {
|
||||
func (p *permissionDialogCmp) renderFetchContent() string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.BaseStyle()
|
||||
|
||||
|
||||
if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
|
||||
content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
|
||||
|
||||
// Use the cache for markdown rendering
|
||||
renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
|
||||
r := styles.GetMarkdownRenderer(p.width-10)
|
||||
r := styles.GetMarkdownRenderer(p.width - 10)
|
||||
s, err := r.Render(content)
|
||||
return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
|
||||
})
|
||||
@@ -325,12 +353,12 @@ func (p *permissionDialogCmp) renderFetchContent() string {
|
||||
func (p *permissionDialogCmp) renderDefaultContent() string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.BaseStyle()
|
||||
|
||||
|
||||
content := p.permission.Description
|
||||
|
||||
// Use the cache for markdown rendering
|
||||
renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
|
||||
r := styles.GetMarkdownRenderer(p.width-10)
|
||||
r := styles.GetMarkdownRenderer(p.width - 10)
|
||||
s, err := r.Render(content)
|
||||
return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
|
||||
})
|
||||
@@ -358,7 +386,7 @@ func (p *permissionDialogCmp) styleViewport() string {
|
||||
func (p *permissionDialogCmp) render() string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.BaseStyle()
|
||||
|
||||
|
||||
title := baseStyle.
|
||||
Bold(true).
|
||||
Width(p.width - 4).
|
||||
|
||||
+139
-21
@@ -10,14 +10,17 @@ import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/opencode-ai/opencode/internal/app"
|
||||
"github.com/opencode-ai/opencode/internal/config"
|
||||
"github.com/opencode-ai/opencode/internal/llm/agent"
|
||||
"github.com/opencode-ai/opencode/internal/logging"
|
||||
"github.com/opencode-ai/opencode/internal/permission"
|
||||
"github.com/opencode-ai/opencode/internal/pubsub"
|
||||
"github.com/opencode-ai/opencode/internal/session"
|
||||
"github.com/opencode-ai/opencode/internal/tui/components/chat"
|
||||
"github.com/opencode-ai/opencode/internal/tui/components/core"
|
||||
"github.com/opencode-ai/opencode/internal/tui/components/dialog"
|
||||
"github.com/opencode-ai/opencode/internal/tui/layout"
|
||||
"github.com/opencode-ai/opencode/internal/tui/page"
|
||||
"github.com/opencode-ai/opencode/internal/tui/theme"
|
||||
"github.com/opencode-ai/opencode/internal/tui/util"
|
||||
)
|
||||
|
||||
@@ -32,6 +35,8 @@ type keyMap struct {
|
||||
SwitchTheme key.Binding
|
||||
}
|
||||
|
||||
type startCompactSessionMsg struct{}
|
||||
|
||||
const (
|
||||
quitKey = "q"
|
||||
)
|
||||
@@ -91,13 +96,14 @@ var logsKeyReturnKey = key.NewBinding(
|
||||
)
|
||||
|
||||
type appModel struct {
|
||||
width, height int
|
||||
currentPage page.PageID
|
||||
previousPage page.PageID
|
||||
pages map[page.PageID]tea.Model
|
||||
loadedPages map[page.PageID]bool
|
||||
status core.StatusCmp
|
||||
app *app.App
|
||||
width, height int
|
||||
currentPage page.PageID
|
||||
previousPage page.PageID
|
||||
pages map[page.PageID]tea.Model
|
||||
loadedPages map[page.PageID]bool
|
||||
status core.StatusCmp
|
||||
app *app.App
|
||||
selectedSession session.Session
|
||||
|
||||
showPermissions bool
|
||||
permissions dialog.PermissionDialogCmp
|
||||
@@ -126,9 +132,12 @@ type appModel struct {
|
||||
|
||||
showThemeDialog bool
|
||||
themeDialog dialog.ThemeDialog
|
||||
|
||||
|
||||
showArgumentsDialog bool
|
||||
argumentsDialog dialog.ArgumentsDialogCmp
|
||||
|
||||
isCompacting bool
|
||||
compactingMessage string
|
||||
}
|
||||
|
||||
func (a appModel) Init() tea.Cmd {
|
||||
@@ -151,6 +160,7 @@ func (a appModel) Init() tea.Cmd {
|
||||
cmd = a.initDialog.Init()
|
||||
cmds = append(cmds, cmd)
|
||||
cmd = a.filepicker.Init()
|
||||
cmds = append(cmds, cmd)
|
||||
cmd = a.themeDialog.Init()
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
@@ -203,7 +213,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
cmds = append(cmds, filepickerCmd)
|
||||
|
||||
a.initDialog.SetSize(msg.Width, msg.Height)
|
||||
|
||||
|
||||
if a.showArgumentsDialog {
|
||||
a.argumentsDialog.SetSize(msg.Width, msg.Height)
|
||||
args, argsCmd := a.argumentsDialog.Update(msg)
|
||||
@@ -293,6 +303,70 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
a.showCommandDialog = false
|
||||
return a, nil
|
||||
|
||||
case startCompactSessionMsg:
|
||||
// Start compacting the current session
|
||||
a.isCompacting = true
|
||||
a.compactingMessage = "Starting summarization..."
|
||||
|
||||
if a.selectedSession.ID == "" {
|
||||
a.isCompacting = false
|
||||
return a, util.ReportWarn("No active session to summarize")
|
||||
}
|
||||
|
||||
// Start the summarization process
|
||||
return a, func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
a.app.CoderAgent.Summarize(ctx, a.selectedSession.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
case pubsub.Event[agent.AgentEvent]:
|
||||
payload := msg.Payload
|
||||
if payload.Error != nil {
|
||||
a.isCompacting = false
|
||||
return a, util.ReportError(payload.Error)
|
||||
}
|
||||
|
||||
a.compactingMessage = payload.Progress
|
||||
|
||||
if payload.Done && payload.Type == agent.AgentEventTypeSummarize {
|
||||
a.isCompacting = false
|
||||
|
||||
if payload.SessionID != "" {
|
||||
// Switch to the new session
|
||||
return a, func() tea.Msg {
|
||||
sessions, err := a.app.Sessions.List(context.Background())
|
||||
if err != nil {
|
||||
return util.InfoMsg{
|
||||
Type: util.InfoTypeError,
|
||||
Msg: "Failed to list sessions: " + err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range sessions {
|
||||
if s.ID == payload.SessionID {
|
||||
return dialog.SessionSelectedMsg{Session: s}
|
||||
}
|
||||
}
|
||||
|
||||
return util.InfoMsg{
|
||||
Type: util.InfoTypeError,
|
||||
Msg: "Failed to find new session",
|
||||
}
|
||||
}
|
||||
}
|
||||
return a, util.ReportInfo("Session summarization complete")
|
||||
} else if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSession.ID != "" {
|
||||
model := a.app.CoderAgent.Model()
|
||||
contextWindow := model.ContextWindow
|
||||
tokens := a.selectedSession.CompletionTokens + a.selectedSession.PromptTokens
|
||||
if (tokens >= int64(float64(contextWindow)*0.95)) && config.Get().AutoCompact {
|
||||
return a, util.CmdHandler(startCompactSessionMsg{})
|
||||
}
|
||||
}
|
||||
// Continue listening for events
|
||||
return a, nil
|
||||
|
||||
case dialog.CloseThemeDialogMsg:
|
||||
a.showThemeDialog = false
|
||||
return a, nil
|
||||
@@ -342,7 +416,13 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return a, nil
|
||||
|
||||
case chat.SessionSelectedMsg:
|
||||
a.selectedSession = msg
|
||||
a.sessionDialog.SetSelectedSession(msg.ID)
|
||||
|
||||
case pubsub.Event[session.Session]:
|
||||
if msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == a.selectedSession.ID {
|
||||
a.selectedSession = msg.Payload
|
||||
}
|
||||
case dialog.SessionSelectedMsg:
|
||||
a.showSessionDialog = false
|
||||
if a.currentPage == page.ChatPage {
|
||||
@@ -357,22 +437,22 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return a, msg.Command.Handler(msg.Command)
|
||||
}
|
||||
return a, util.ReportInfo("Command selected: " + msg.Command.Title)
|
||||
|
||||
|
||||
case dialog.ShowArgumentsDialogMsg:
|
||||
// Show arguments dialog
|
||||
a.argumentsDialog = dialog.NewArgumentsDialogCmp(msg.CommandID, msg.Content)
|
||||
a.showArgumentsDialog = true
|
||||
return a, a.argumentsDialog.Init()
|
||||
|
||||
|
||||
case dialog.CloseArgumentsDialogMsg:
|
||||
// Close arguments dialog
|
||||
a.showArgumentsDialog = false
|
||||
|
||||
|
||||
// If submitted, replace $ARGUMENTS and run the command
|
||||
if msg.Submit {
|
||||
// Replace $ARGUMENTS with the provided arguments
|
||||
content := strings.ReplaceAll(msg.Content, "$ARGUMENTS", msg.Arguments)
|
||||
|
||||
|
||||
// Execute the command with arguments
|
||||
return a, util.CmdHandler(dialog.CommandRunCustomMsg{
|
||||
Content: content,
|
||||
@@ -387,7 +467,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
a.argumentsDialog = args.(dialog.ArgumentsDialogCmp)
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
|
||||
switch {
|
||||
|
||||
case key.Matches(msg, keys.Quit):
|
||||
@@ -606,6 +686,15 @@ func (a *appModel) RegisterCommand(cmd dialog.Command) {
|
||||
a.commands = append(a.commands, cmd)
|
||||
}
|
||||
|
||||
func (a *appModel) findCommand(id string) (dialog.Command, bool) {
|
||||
for _, cmd := range a.commands {
|
||||
if cmd.ID == id {
|
||||
return cmd, true
|
||||
}
|
||||
}
|
||||
return dialog.Command{}, false
|
||||
}
|
||||
|
||||
func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
|
||||
if a.app.CoderAgent.IsBusy() {
|
||||
// For now we don't move to any page if the agent is busy
|
||||
@@ -668,10 +757,29 @@ func (a appModel) View() string {
|
||||
|
||||
}
|
||||
|
||||
if !a.app.CoderAgent.IsBusy() {
|
||||
a.status.SetHelpWidgetMsg("ctrl+? help")
|
||||
} else {
|
||||
a.status.SetHelpWidgetMsg("? help")
|
||||
// Show compacting status overlay
|
||||
if a.isCompacting {
|
||||
t := theme.CurrentTheme()
|
||||
style := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(t.BorderFocused()).
|
||||
BorderBackground(t.Background()).
|
||||
Padding(1, 2).
|
||||
Background(t.Background()).
|
||||
Foreground(t.Text())
|
||||
|
||||
overlay := style.Render("Summarizing\n" + a.compactingMessage)
|
||||
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.showHelp {
|
||||
@@ -789,7 +897,7 @@ func (a appModel) View() string {
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
if a.showArgumentsDialog {
|
||||
overlay := a.argumentsDialog.View()
|
||||
row := lipgloss.Height(appView) / 2
|
||||
@@ -850,7 +958,17 @@ If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
model.RegisterCommand(dialog.Command{
|
||||
ID: "compact",
|
||||
Title: "Compact Session",
|
||||
Description: "Summarize the current session and create a new one with the summary",
|
||||
Handler: func(cmd dialog.Command) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return startCompactSessionMsg{}
|
||||
}
|
||||
},
|
||||
})
|
||||
// Load custom commands
|
||||
customCommands, err := dialog.LoadCustomCommands()
|
||||
if err != nil {
|
||||
@@ -860,6 +978,6 @@ If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (
|
||||
model.RegisterCommand(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user