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:
Kujtim Hoxha 2025-05-09 19:30:57 +02:00 committed by GitHub
commit 90084ce43d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 537 additions and 98 deletions

View file

@ -62,12 +62,29 @@ OpenCode looks for configuration in the following locations:
- `$XDG_CONFIG_HOME/opencode/.opencode.json` - `$XDG_CONFIG_HOME/opencode/.opencode.json`
- `./.opencode.json` (local directory) - `./.opencode.json` (local directory)
### Auto Compact Feature
OpenCode includes an auto compact feature that automatically summarizes your conversation when it approaches the model's context window limit. When enabled (default setting), this feature:
- Monitors token usage during your conversation
- Automatically triggers summarization when usage reaches 95% of the model's context window
- Creates a new session with the summary, allowing you to continue your work without losing context
- Helps prevent "out of context" errors that can occur with long conversations
You can enable or disable this feature in your configuration file:
```json
{
"autoCompact": true // default is true
}
```
### Environment Variables ### Environment Variables
You can configure OpenCode using environment variables: You can configure OpenCode using environment variables:
| Environment Variable | Purpose | | Environment Variable | Purpose |
|----------------------------|--------------------------------------------------------| | -------------------------- | ------------------------------------------------------ |
| `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 |
@ -79,7 +96,6 @@ You can configure OpenCode using environment variables:
| `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
@ -134,7 +150,8 @@ You can configure OpenCode using environment variables:
} }
}, },
"debug": false, "debug": false,
"debugLSP": false "debugLSP": false,
"autoCompact": true
} }
``` ```
@ -327,9 +344,11 @@ OpenCode supports custom commands that can be created by users to quickly send p
Custom commands are predefined prompts stored as Markdown files in one of three locations: Custom commands are predefined prompts stored as Markdown files in one of three locations:
1. **User Commands** (prefixed with `user:`): 1. **User Commands** (prefixed with `user:`):
``` ```
$XDG_CONFIG_HOME/opencode/commands/ $XDG_CONFIG_HOME/opencode/commands/
``` ```
(typically `~/.config/opencode/commands/` on Linux/macOS) (typically `~/.config/opencode/commands/` on Linux/macOS)
or or
@ -382,6 +401,15 @@ This creates a command with ID `user:git:commit`.
The content of the command file will be sent as a message to the AI assistant. The content of the command file will be sent as a message to the AI assistant.
### Built-in Commands
OpenCode includes several built-in commands:
| Command | Description |
| ------------------ | --------------------------------------------------------------------------------------------------- |
| Initialize Project | Creates or updates the OpenCode.md memory file with project-specific information |
| Compact Session | Manually triggers the summarization of the current session, creating a new session with the summary |
## MCP (Model Context Protocol) ## MCP (Model Context Protocol)
OpenCode implements the Model Context Protocol (MCP) to extend its capabilities through external tools. MCP provides a standardized way for the AI assistant to interact with external services and tools. OpenCode implements the Model Context Protocol (MCP) to extend its capabilities through external tools. MCP provides a standardized way for the AI assistant to interact with external services and tools.

View file

@ -218,6 +218,7 @@ func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg,
setupSubscriber(ctx, &wg, "sessions", app.Sessions.Subscribe, ch) setupSubscriber(ctx, &wg, "sessions", app.Sessions.Subscribe, ch)
setupSubscriber(ctx, &wg, "messages", app.Messages.Subscribe, ch) setupSubscriber(ctx, &wg, "messages", app.Messages.Subscribe, ch)
setupSubscriber(ctx, &wg, "permissions", app.Permissions.Subscribe, ch) setupSubscriber(ctx, &wg, "permissions", app.Permissions.Subscribe, ch)
setupSubscriber(ctx, &wg, "coderAgent", app.CoderAgent.Subscribe, ch)
cleanupFunc := func() { cleanupFunc := func() {
logging.Info("Cancelling all subscriptions") logging.Info("Cancelling all subscriptions")

View file

@ -36,9 +36,10 @@ type MCPServer struct {
type AgentName string type AgentName string
const ( const (
AgentCoder AgentName = "coder" AgentCoder AgentName = "coder"
AgentTask AgentName = "task" AgentSummarizer AgentName = "summarizer"
AgentTitle AgentName = "title" AgentTask AgentName = "task"
AgentTitle AgentName = "title"
) )
// Agent defines configuration for different LLM models and their token limits. // Agent defines configuration for different LLM models and their token limits.
@ -84,6 +85,7 @@ type Config struct {
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"`
AutoCompact bool `json:"autoCompact,omitempty"`
} }
// Application constants // Application constants
@ -213,6 +215,7 @@ func setDefaults(debug bool) {
viper.SetDefault("data.directory", defaultDataDirectory) viper.SetDefault("data.directory", defaultDataDirectory)
viper.SetDefault("contextPaths", defaultContextPaths) viper.SetDefault("contextPaths", defaultContextPaths)
viper.SetDefault("tui.theme", "opencode") viper.SetDefault("tui.theme", "opencode")
viper.SetDefault("autoCompact", true)
if debug { if debug {
viper.SetDefault("debug", true) viper.SetDefault("debug", true)
@ -262,6 +265,7 @@ func setProviderDefaults() {
// 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.coder.model", models.Claude37Sonnet) viper.SetDefault("agents.coder.model", models.Claude37Sonnet)
viper.SetDefault("agents.summarizer.model", models.Claude37Sonnet)
viper.SetDefault("agents.task.model", models.Claude37Sonnet) viper.SetDefault("agents.task.model", models.Claude37Sonnet)
viper.SetDefault("agents.title.model", models.Claude37Sonnet) viper.SetDefault("agents.title.model", models.Claude37Sonnet)
return return
@ -270,6 +274,7 @@ func setProviderDefaults() {
// OpenAI configuration // OpenAI configuration
if key := viper.GetString("providers.openai.apiKey"); strings.TrimSpace(key) != "" { if key := viper.GetString("providers.openai.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.coder.model", models.GPT41) viper.SetDefault("agents.coder.model", models.GPT41)
viper.SetDefault("agents.summarizer.model", models.GPT41)
viper.SetDefault("agents.task.model", models.GPT41Mini) viper.SetDefault("agents.task.model", models.GPT41Mini)
viper.SetDefault("agents.title.model", models.GPT41Mini) viper.SetDefault("agents.title.model", models.GPT41Mini)
return return
@ -278,6 +283,7 @@ func setProviderDefaults() {
// Google Gemini configuration // Google Gemini configuration
if key := viper.GetString("providers.gemini.apiKey"); strings.TrimSpace(key) != "" { if key := viper.GetString("providers.gemini.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.coder.model", models.Gemini25) viper.SetDefault("agents.coder.model", models.Gemini25)
viper.SetDefault("agents.summarizer.model", models.Gemini25)
viper.SetDefault("agents.task.model", models.Gemini25Flash) viper.SetDefault("agents.task.model", models.Gemini25Flash)
viper.SetDefault("agents.title.model", models.Gemini25Flash) viper.SetDefault("agents.title.model", models.Gemini25Flash)
return return
@ -286,6 +292,7 @@ func setProviderDefaults() {
// Groq configuration // Groq configuration
if key := viper.GetString("providers.groq.apiKey"); strings.TrimSpace(key) != "" { if key := viper.GetString("providers.groq.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.coder.model", models.QWENQwq) viper.SetDefault("agents.coder.model", models.QWENQwq)
viper.SetDefault("agents.summarizer.model", models.QWENQwq)
viper.SetDefault("agents.task.model", models.QWENQwq) viper.SetDefault("agents.task.model", models.QWENQwq)
viper.SetDefault("agents.title.model", models.QWENQwq) viper.SetDefault("agents.title.model", models.QWENQwq)
return return
@ -294,6 +301,7 @@ func setProviderDefaults() {
// OpenRouter configuration // OpenRouter configuration
if key := viper.GetString("providers.openrouter.apiKey"); strings.TrimSpace(key) != "" { if key := viper.GetString("providers.openrouter.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.coder.model", models.OpenRouterClaude37Sonnet) viper.SetDefault("agents.coder.model", models.OpenRouterClaude37Sonnet)
viper.SetDefault("agents.summarizer.model", models.OpenRouterClaude37Sonnet)
viper.SetDefault("agents.task.model", models.OpenRouterClaude37Sonnet) viper.SetDefault("agents.task.model", models.OpenRouterClaude37Sonnet)
viper.SetDefault("agents.title.model", models.OpenRouterClaude35Haiku) viper.SetDefault("agents.title.model", models.OpenRouterClaude35Haiku)
return return
@ -302,6 +310,7 @@ func setProviderDefaults() {
// XAI configuration // XAI configuration
if key := viper.GetString("providers.xai.apiKey"); strings.TrimSpace(key) != "" { if key := viper.GetString("providers.xai.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.coder.model", models.XAIGrok3Beta) viper.SetDefault("agents.coder.model", models.XAIGrok3Beta)
viper.SetDefault("agents.summarizer.model", models.XAIGrok3Beta)
viper.SetDefault("agents.task.model", models.XAIGrok3Beta) viper.SetDefault("agents.task.model", models.XAIGrok3Beta)
viper.SetDefault("agents.title.model", models.XAiGrok3MiniFastBeta) viper.SetDefault("agents.title.model", models.XAiGrok3MiniFastBeta)
return return
@ -310,6 +319,7 @@ func setProviderDefaults() {
// AWS Bedrock configuration // AWS Bedrock configuration
if hasAWSCredentials() { if hasAWSCredentials() {
viper.SetDefault("agents.coder.model", models.BedrockClaude37Sonnet) viper.SetDefault("agents.coder.model", models.BedrockClaude37Sonnet)
viper.SetDefault("agents.summarizer.model", models.BedrockClaude37Sonnet)
viper.SetDefault("agents.task.model", models.BedrockClaude37Sonnet) viper.SetDefault("agents.task.model", models.BedrockClaude37Sonnet)
viper.SetDefault("agents.title.model", models.BedrockClaude37Sonnet) viper.SetDefault("agents.title.model", models.BedrockClaude37Sonnet)
return return
@ -318,6 +328,7 @@ func setProviderDefaults() {
// Azure OpenAI configuration // Azure OpenAI configuration
if os.Getenv("AZURE_OPENAI_ENDPOINT") != "" { if os.Getenv("AZURE_OPENAI_ENDPOINT") != "" {
viper.SetDefault("agents.coder.model", models.AzureGPT41) viper.SetDefault("agents.coder.model", models.AzureGPT41)
viper.SetDefault("agents.summarizer.model", models.AzureGPT41)
viper.SetDefault("agents.task.model", models.AzureGPT41Mini) viper.SetDefault("agents.task.model", models.AzureGPT41Mini)
viper.SetDefault("agents.title.model", models.AzureGPT41Mini) viper.SetDefault("agents.title.model", models.AzureGPT41Mini)
return return

View file

@ -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) return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", err)
} }
result := <-done result := <-done
if result.Err() != nil { if result.Error != nil {
return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", result.Err()) return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", result.Error)
} }
response := result.Response() response := result.Message
if response.Role != message.Assistant { if response.Role != message.Assistant {
return tools.NewTextErrorResponse("no response"), nil 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.Cost += updatedSession.Cost
parentSession.PromptTokens += updatedSession.PromptTokens
parentSession.CompletionTokens += updatedSession.CompletionTokens
_, err = b.sessions.Save(ctx, parentSession) _, err = b.sessions.Save(ctx, parentSession)
if err != nil { if err != nil {

View file

@ -15,6 +15,7 @@ import (
"github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/message" "github.com/opencode-ai/opencode/internal/message"
"github.com/opencode-ai/opencode/internal/permission" "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/session"
) )
@ -24,35 +25,46 @@ var (
ErrSessionBusy = errors.New("session is currently processing another request") 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 { type AgentEvent struct {
message message.Message Type AgentEventType
err error Message message.Message
} Error error
func (e *AgentEvent) Err() error { // When summarizing
return e.err SessionID string
} Progress string
Done bool
func (e *AgentEvent) Response() message.Message {
return e.message
} }
type Service interface { type Service interface {
pubsub.Suscriber[AgentEvent]
Model() models.Model
Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error) Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
Cancel(sessionID string) Cancel(sessionID string)
IsSessionBusy(sessionID string) bool IsSessionBusy(sessionID string) bool
IsBusy() bool IsBusy() bool
Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error) Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error)
Summarize(ctx context.Context, sessionID string) error
} }
type agent struct { type agent struct {
*pubsub.Broker[AgentEvent]
sessions session.Service sessions session.Service
messages message.Service messages message.Service
tools []tools.BaseTool tools []tools.BaseTool
provider provider.Provider provider provider.Provider
titleProvider provider.Provider titleProvider provider.Provider
summarizeProvider provider.Provider
activeRequests sync.Map activeRequests sync.Map
} }
@ -75,26 +87,48 @@ func NewAgent(
return nil, err return nil, err
} }
} }
var summarizeProvider provider.Provider
if agentName == config.AgentCoder {
summarizeProvider, err = createAgentProvider(config.AgentSummarizer)
if err != nil {
return nil, err
}
}
agent := &agent{ agent := &agent{
provider: agentProvider, Broker: pubsub.NewBroker[AgentEvent](),
messages: messages, provider: agentProvider,
sessions: sessions, messages: messages,
tools: agentTools, sessions: sessions,
titleProvider: titleProvider, tools: agentTools,
activeRequests: sync.Map{}, titleProvider: titleProvider,
summarizeProvider: summarizeProvider,
activeRequests: sync.Map{},
} }
return agent, nil return agent, nil
} }
func (a *agent) Model() models.Model {
return a.provider.Model()
}
func (a *agent) Cancel(sessionID string) { func (a *agent) Cancel(sessionID string) {
// Cancel regular requests
if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID); exists { if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID); exists {
if cancel, ok := cancelFunc.(context.CancelFunc); ok { if cancel, ok := cancelFunc.(context.CancelFunc); ok {
logging.InfoPersist(fmt.Sprintf("Request cancellation initiated for session: %s", sessionID)) logging.InfoPersist(fmt.Sprintf("Request cancellation initiated for session: %s", sessionID))
cancel() 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 { 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 { func (a *agent) err(err error) AgentEvent {
return 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}) attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
} }
result := a.processGeneration(genCtx, sessionID, content, attachmentParts) result := a.processGeneration(genCtx, sessionID, content, attachmentParts)
if result.Err() != nil && !errors.Is(result.Err(), ErrRequestCancelled) && !errors.Is(result.Err(), context.Canceled) { if result.Error != nil && !errors.Is(result.Error, ErrRequestCancelled) && !errors.Is(result.Error, context.Canceled) {
logging.ErrorPersist(result.Err().Error()) logging.ErrorPersist(result.Error.Error())
} }
logging.Debug("Request completed", "sessionID", sessionID) logging.Debug("Request completed", "sessionID", sessionID)
a.activeRequests.Delete(sessionID) a.activeRequests.Delete(sessionID)
cancel() cancel()
a.Publish(pubsub.CreatedEvent, result)
events <- result events <- result
close(events) close(events)
}() }()
@ -241,7 +277,9 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
continue continue
} }
return AgentEvent{ 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) model.CostPer1MOut/1e6*float64(usage.OutputTokens)
sess.Cost += cost sess.Cost += cost
sess.CompletionTokens += usage.OutputTokens sess.CompletionTokens = usage.OutputTokens + usage.CacheReadTokens
sess.PromptTokens += usage.InputTokens sess.PromptTokens = usage.InputTokens + usage.CacheCreationTokens
_, err = a.sessions.Save(ctx, sess) _, err = a.sessions.Save(ctx, sess)
if err != nil { if err != nil {
@ -461,6 +499,162 @@ func (a *agent) Update(agentName config.AgentName, modelID models.ModelID) (mode
return a.provider.Model(), nil 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) { func createAgentProvider(agentName config.AgentName) (provider.Provider, error) {
cfg := config.Get() cfg := config.Get()
agentConfig, ok := cfg.Agents[agentName] agentConfig, ok := cfg.Agents[agentName]

View file

@ -21,6 +21,8 @@ func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) s
basePrompt = TitlePrompt(provider) basePrompt = TitlePrompt(provider)
case config.AgentTask: case config.AgentTask:
basePrompt = TaskPrompt(provider) basePrompt = TaskPrompt(provider)
case config.AgentSummarizer:
basePrompt = SummarizerPrompt(provider)
default: default:
basePrompt = "You are a helpful assistant" basePrompt = "You are a helpful assistant"
} }

View file

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

View file

@ -21,7 +21,6 @@ import (
type StatusCmp interface { type StatusCmp interface {
tea.Model tea.Model
SetHelpWidgetMsg(string)
} }
type statusCmp struct { type statusCmp struct {
@ -74,11 +73,9 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var helpWidget = "" var helpWidget = ""
// getHelpWidget returns the help widget with current theme colors // getHelpWidget returns the help widget with current theme colors
func getHelpWidget(helpText string) string { func getHelpWidget() string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
if helpText == "" { helpText := "ctrl+? help"
helpText = "ctrl+? help"
}
return styles.Padded(). return styles.Padded().
Background(t.TextMuted()). Background(t.TextMuted()).
@ -87,7 +84,7 @@ func getHelpWidget(helpText string) string {
Render(helpText) 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) // Format tokens in human-readable format (e.g., 110K, 1.2M)
var formattedTokens string var formattedTokens string
switch { switch {
@ -110,32 +107,48 @@ func formatTokensAndCost(tokens int64, cost float64) string {
// Format cost with $ symbol and 2 decimal places // Format cost with $ symbol and 2 decimal places
formattedCost := fmt.Sprintf("$%.2f", cost) 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 { func (m statusCmp) View() string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
modelID := config.Get().Agents[config.AgentCoder].Model
model := models.SupportedModels[modelID]
// Initialize the help widget // Initialize the help widget
status := getHelpWidget("") status := getHelpWidget()
tokenInfoWidth := 0
if m.session.ID != "" { 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(). tokensStyle := styles.Padded().
Background(t.Text()). Background(t.Text()).
Foreground(t.BackgroundSecondary()). Foreground(t.BackgroundSecondary())
Render(tokens) percentage := (float64(totalTokens) / float64(model.ContextWindow)) * 100
status += tokensStyle if percentage > 80 {
tokensStyle = tokensStyle.Background(t.Warning())
}
tokenInfoWidth = lipgloss.Width(tokens) + 2
status += tokensStyle.Render(tokens)
} }
diagnostics := styles.Padded(). diagnostics := styles.Padded().
Background(t.BackgroundDarker()). Background(t.BackgroundDarker()).
Render(m.projectDiagnostics()) Render(m.projectDiagnostics())
availableWidht := max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokenInfoWidth)
if m.info.Msg != "" { if m.info.Msg != "" {
infoStyle := styles.Padded(). infoStyle := styles.Padded().
Foreground(t.Background()). Foreground(t.Background()).
Width(m.availableFooterMsgWidth(diagnostics)) Width(availableWidht)
switch m.info.Type { switch m.info.Type {
case util.InfoTypeInfo: case util.InfoTypeInfo:
@ -146,18 +159,18 @@ func (m statusCmp) View() string {
infoStyle = infoStyle.Background(t.Error()) infoStyle = infoStyle.Background(t.Error())
} }
infoWidth := availableWidht - 10
// Truncate message if it's longer than available width // Truncate message if it's longer than available width
msg := m.info.Msg msg := m.info.Msg
availWidth := m.availableFooterMsgWidth(diagnostics) - 10 if len(msg) > infoWidth && infoWidth > 0 {
if len(msg) > availWidth && availWidth > 0 { msg = msg[:infoWidth] + "..."
msg = msg[:availWidth] + "..."
} }
status += infoStyle.Render(msg) status += infoStyle.Render(msg)
} else { } else {
status += styles.Padded(). status += styles.Padded().
Foreground(t.Text()). Foreground(t.Text()).
Background(t.BackgroundSecondary()). Background(t.BackgroundSecondary()).
Width(m.availableFooterMsgWidth(diagnostics)). Width(availableWidht).
Render("") Render("")
} }
@ -245,12 +258,10 @@ func (m *statusCmp) projectDiagnostics() string {
return strings.Join(diagnostics, " ") return strings.Join(diagnostics, " ")
} }
func (m statusCmp) availableFooterMsgWidth(diagnostics string) int { func (m statusCmp) availableFooterMsgWidth(diagnostics, tokenInfo string) int {
tokens := ""
tokensWidth := 0 tokensWidth := 0
if m.session.ID != "" { if m.session.ID != "" {
tokens = formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost) tokensWidth = lipgloss.Width(tokenInfo) + 2
tokensWidth = lipgloss.Width(tokens) + 2
} }
return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokensWidth) 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) 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 { func NewStatusCmp(lspClients map[string]*lsp.Client) StatusCmp {
// Initialize the help widget with default text helpWidget = getHelpWidget()
helpWidget = getHelpWidget("")
return &statusCmp{ return &statusCmp{
messageTTL: 10 * time.Second, messageTTL: 10 * time.Second,

View file

@ -302,11 +302,8 @@ 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
} }
// No need to reassign filename if it's not changing
files = append(files, itemStyle.Padding(0, 1).Render(filename)) files = append(files, itemStyle.Padding(0, 1).Render(filename))
} }

View file

@ -2,6 +2,8 @@ package dialog
import ( import (
"fmt" "fmt"
"strings"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea" 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/styles"
"github.com/opencode-ai/opencode/internal/tui/theme" "github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util" "github.com/opencode-ai/opencode/internal/tui/util"
"strings"
) )
type PermissionAction string type PermissionAction string
@ -150,7 +151,7 @@ func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
func (p *permissionDialogCmp) renderButtons() string { func (p *permissionDialogCmp) renderButtons() string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
baseStyle := styles.BaseStyle() baseStyle := styles.BaseStyle()
allowStyle := baseStyle allowStyle := baseStyle
allowSessionStyle := baseStyle allowSessionStyle := baseStyle
denyStyle := baseStyle denyStyle := baseStyle
@ -196,7 +197,7 @@ func (p *permissionDialogCmp) renderButtons() string {
func (p *permissionDialogCmp) renderHeader() string { func (p *permissionDialogCmp) renderHeader() string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
baseStyle := styles.BaseStyle() baseStyle := styles.BaseStyle()
toolKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Tool") toolKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Tool")
toolValue := baseStyle. toolValue := baseStyle.
Foreground(t.Text()). Foreground(t.Text()).
@ -229,9 +230,36 @@ func (p *permissionDialogCmp) renderHeader() string {
case tools.BashToolName: case tools.BashToolName:
headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Command")) headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Command"))
case tools.EditToolName: 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: 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: case tools.FetchToolName:
headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("URL")) 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 { func (p *permissionDialogCmp) renderBashContent() string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
baseStyle := styles.BaseStyle() baseStyle := styles.BaseStyle()
if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok { if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
content := fmt.Sprintf("```bash\n%s\n```", pr.Command) content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
// Use the cache for markdown rendering // Use the cache for markdown rendering
renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) { 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) s, err := r.Render(content)
return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
}) })
@ -302,13 +330,13 @@ func (p *permissionDialogCmp) renderWriteContent() string {
func (p *permissionDialogCmp) renderFetchContent() string { func (p *permissionDialogCmp) renderFetchContent() string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
baseStyle := styles.BaseStyle() baseStyle := styles.BaseStyle()
if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok { if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
content := fmt.Sprintf("```bash\n%s\n```", pr.URL) content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
// Use the cache for markdown rendering // Use the cache for markdown rendering
renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) { 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) s, err := r.Render(content)
return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
}) })
@ -325,12 +353,12 @@ func (p *permissionDialogCmp) renderFetchContent() string {
func (p *permissionDialogCmp) renderDefaultContent() string { func (p *permissionDialogCmp) renderDefaultContent() string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
baseStyle := styles.BaseStyle() baseStyle := styles.BaseStyle()
content := p.permission.Description content := p.permission.Description
// Use the cache for markdown rendering // Use the cache for markdown rendering
renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) { 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) s, err := r.Render(content)
return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
}) })
@ -358,7 +386,7 @@ func (p *permissionDialogCmp) styleViewport() string {
func (p *permissionDialogCmp) render() string { func (p *permissionDialogCmp) render() string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
baseStyle := styles.BaseStyle() baseStyle := styles.BaseStyle()
title := baseStyle. title := baseStyle.
Bold(true). Bold(true).
Width(p.width - 4). Width(p.width - 4).

View file

@ -10,14 +10,17 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/opencode-ai/opencode/internal/app" "github.com/opencode-ai/opencode/internal/app"
"github.com/opencode-ai/opencode/internal/config" "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/logging"
"github.com/opencode-ai/opencode/internal/permission" "github.com/opencode-ai/opencode/internal/permission"
"github.com/opencode-ai/opencode/internal/pubsub" "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/chat"
"github.com/opencode-ai/opencode/internal/tui/components/core" "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/components/dialog"
"github.com/opencode-ai/opencode/internal/tui/layout" "github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/page" "github.com/opencode-ai/opencode/internal/tui/page"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util" "github.com/opencode-ai/opencode/internal/tui/util"
) )
@ -32,6 +35,8 @@ type keyMap struct {
SwitchTheme key.Binding SwitchTheme key.Binding
} }
type startCompactSessionMsg struct{}
const ( const (
quitKey = "q" quitKey = "q"
) )
@ -91,13 +96,14 @@ var logsKeyReturnKey = key.NewBinding(
) )
type appModel struct { type appModel struct {
width, height int width, height int
currentPage page.PageID currentPage page.PageID
previousPage page.PageID previousPage page.PageID
pages map[page.PageID]tea.Model pages map[page.PageID]tea.Model
loadedPages map[page.PageID]bool loadedPages map[page.PageID]bool
status core.StatusCmp status core.StatusCmp
app *app.App app *app.App
selectedSession session.Session
showPermissions bool showPermissions bool
permissions dialog.PermissionDialogCmp permissions dialog.PermissionDialogCmp
@ -126,9 +132,12 @@ type appModel struct {
showThemeDialog bool showThemeDialog bool
themeDialog dialog.ThemeDialog themeDialog dialog.ThemeDialog
showArgumentsDialog bool showArgumentsDialog bool
argumentsDialog dialog.ArgumentsDialogCmp argumentsDialog dialog.ArgumentsDialogCmp
isCompacting bool
compactingMessage string
} }
func (a appModel) Init() tea.Cmd { func (a appModel) Init() tea.Cmd {
@ -151,6 +160,7 @@ func (a appModel) Init() tea.Cmd {
cmd = a.initDialog.Init() cmd = a.initDialog.Init()
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
cmd = a.filepicker.Init() cmd = a.filepicker.Init()
cmds = append(cmds, cmd)
cmd = a.themeDialog.Init() cmd = a.themeDialog.Init()
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
@ -203,7 +213,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, filepickerCmd) cmds = append(cmds, filepickerCmd)
a.initDialog.SetSize(msg.Width, msg.Height) a.initDialog.SetSize(msg.Width, msg.Height)
if a.showArgumentsDialog { if a.showArgumentsDialog {
a.argumentsDialog.SetSize(msg.Width, msg.Height) a.argumentsDialog.SetSize(msg.Width, msg.Height)
args, argsCmd := a.argumentsDialog.Update(msg) args, argsCmd := a.argumentsDialog.Update(msg)
@ -293,6 +303,70 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.showCommandDialog = false a.showCommandDialog = false
return a, nil 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: case dialog.CloseThemeDialogMsg:
a.showThemeDialog = false a.showThemeDialog = false
return a, nil return a, nil
@ -342,7 +416,13 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, nil return a, nil
case chat.SessionSelectedMsg: case chat.SessionSelectedMsg:
a.selectedSession = msg
a.sessionDialog.SetSelectedSession(msg.ID) 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: case dialog.SessionSelectedMsg:
a.showSessionDialog = false a.showSessionDialog = false
if a.currentPage == page.ChatPage { 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, msg.Command.Handler(msg.Command)
} }
return a, util.ReportInfo("Command selected: " + msg.Command.Title) return a, util.ReportInfo("Command selected: " + msg.Command.Title)
case dialog.ShowArgumentsDialogMsg: case dialog.ShowArgumentsDialogMsg:
// Show arguments dialog // Show arguments dialog
a.argumentsDialog = dialog.NewArgumentsDialogCmp(msg.CommandID, msg.Content) a.argumentsDialog = dialog.NewArgumentsDialogCmp(msg.CommandID, msg.Content)
a.showArgumentsDialog = true a.showArgumentsDialog = true
return a, a.argumentsDialog.Init() return a, a.argumentsDialog.Init()
case dialog.CloseArgumentsDialogMsg: case dialog.CloseArgumentsDialogMsg:
// Close arguments dialog // Close arguments dialog
a.showArgumentsDialog = false a.showArgumentsDialog = false
// If submitted, replace $ARGUMENTS and run the command // If submitted, replace $ARGUMENTS and run the command
if msg.Submit { if msg.Submit {
// Replace $ARGUMENTS with the provided arguments // Replace $ARGUMENTS with the provided arguments
content := strings.ReplaceAll(msg.Content, "$ARGUMENTS", msg.Arguments) content := strings.ReplaceAll(msg.Content, "$ARGUMENTS", msg.Arguments)
// 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,
@ -387,7 +467,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.argumentsDialog = args.(dialog.ArgumentsDialogCmp) a.argumentsDialog = args.(dialog.ArgumentsDialogCmp)
return a, cmd return a, cmd
} }
switch { switch {
case key.Matches(msg, keys.Quit): case key.Matches(msg, keys.Quit):
@ -606,6 +686,15 @@ func (a *appModel) RegisterCommand(cmd dialog.Command) {
a.commands = append(a.commands, cmd) 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 { func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
if a.app.CoderAgent.IsBusy() { if a.app.CoderAgent.IsBusy() {
// For now we don't move to any page if the agent is busy // 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() { // Show compacting status overlay
a.status.SetHelpWidgetMsg("ctrl+? help") if a.isCompacting {
} else { t := theme.CurrentTheme()
a.status.SetHelpWidgetMsg("? help") 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 { if a.showHelp {
@ -789,7 +897,7 @@ func (a appModel) View() string {
true, true,
) )
} }
if a.showArgumentsDialog { if a.showArgumentsDialog {
overlay := a.argumentsDialog.View() overlay := a.argumentsDialog.View()
row := lipgloss.Height(appView) / 2 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 // Load custom commands
customCommands, err := dialog.LoadCustomCommands() customCommands, err := dialog.LoadCustomCommands()
if err != nil { if err != nil {
@ -860,6 +978,6 @@ If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (
model.RegisterCommand(cmd) model.RegisterCommand(cmd)
} }
} }
return model return model
} }

41
scripts/check_hidden_chars.sh Executable file
View file

@ -0,0 +1,41 @@
#!/bin/bash
# Script to check for hidden/invisible characters in Go files
# This helps detect potential prompt injection attempts
echo "Checking Go files for hidden characters..."
# Find all Go files in the repository
go_files=$(find . -name "*.go" -type f)
# Counter for files with hidden characters
files_with_hidden=0
for file in $go_files; do
# Check for specific Unicode hidden characters that could be used for prompt injection
# This excludes normal whitespace like tabs and newlines
# Looking for:
# - Zero-width spaces (U+200B)
# - Zero-width non-joiners (U+200C)
# - Zero-width joiners (U+200D)
# - Left-to-right/right-to-left marks (U+200E, U+200F)
# - Bidirectional overrides (U+202A-U+202E)
# - Byte order mark (U+FEFF)
if hexdump -C "$file" | grep -E 'e2 80 8b|e2 80 8c|e2 80 8d|e2 80 8e|e2 80 8f|e2 80 aa|e2 80 ab|e2 80 ac|e2 80 ad|e2 80 ae|ef bb bf' > /dev/null 2>&1; then
echo "Hidden characters found in: $file"
# Show the file with potential issues
echo " Hexdump showing suspicious characters:"
hexdump -C "$file" | grep -E 'e2 80 8b|e2 80 8c|e2 80 8d|e2 80 8e|e2 80 8f|e2 80 aa|e2 80 ab|e2 80 ac|e2 80 ad|e2 80 ae|ef bb bf' | head -10
files_with_hidden=$((files_with_hidden + 1))
fi
done
if [ $files_with_hidden -eq 0 ]; then
echo "No hidden characters found in any Go files."
else
echo "Found hidden characters in $files_with_hidden Go file(s)."
fi
exit $files_with_hidden # Exit with number of affected files as status code