From 90084ce43d7a44c4dea98705694f34d01dbe192a Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 9 May 2025 19:30:57 +0200 Subject: [PATCH] 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 --- README.md | 34 ++- cmd/root.go | 1 + internal/config/config.go | 17 +- internal/llm/agent/agent-tool.go | 8 +- internal/llm/agent/agent.go | 238 +++++++++++++++++-- internal/llm/prompt/prompt.go | 2 + internal/llm/prompt/summarizer.go | 16 ++ internal/tui/components/core/status.go | 61 ++--- internal/tui/components/dialog/filepicker.go | 5 +- internal/tui/components/dialog/permission.go | 52 +++- internal/tui/tui.go | 160 +++++++++++-- scripts/check_hidden_chars.sh | 41 ++++ 12 files changed, 537 insertions(+), 98 deletions(-) create mode 100644 internal/llm/prompt/summarizer.go create mode 100755 scripts/check_hidden_chars.sh diff --git a/README.md b/README.md index ab5d9df7757d0f9ae6cbdc61ec4ed5d3dbde2621..742779875e7713d8da9a42c54fa2d624cb9dd7b5 100644 --- a/README.md +++ b/README.md @@ -62,12 +62,29 @@ OpenCode looks for configuration in the following locations: - `$XDG_CONFIG_HOME/opencode/.opencode.json` - `./.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 You can configure OpenCode using environment variables: | Environment Variable | Purpose | -|----------------------------|--------------------------------------------------------| +| -------------------------- | ------------------------------------------------------ | | `ANTHROPIC_API_KEY` | For Claude models | | `OPENAI_API_KEY` | For OpenAI 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_VERSION` | For Azure OpenAI models | - ### Configuration File Structure ```json @@ -134,7 +150,8 @@ You can configure OpenCode using environment variables: } }, "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: 1. **User Commands** (prefixed with `user:`): + ``` $XDG_CONFIG_HOME/opencode/commands/ ``` + (typically `~/.config/opencode/commands/` on Linux/macOS) 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. +### 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) 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. diff --git a/cmd/root.go b/cmd/root.go index ab81f7120723181a05399ee99eb9e942377d5b0e..a0dd8e68c3b29b2044b96a67d5ad7cf8ddddbb44 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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, "messages", app.Messages.Subscribe, ch) setupSubscriber(ctx, &wg, "permissions", app.Permissions.Subscribe, ch) + setupSubscriber(ctx, &wg, "coderAgent", app.CoderAgent.Subscribe, ch) cleanupFunc := func() { logging.Info("Cancelling all subscriptions") diff --git a/internal/config/config.go b/internal/config/config.go index c825805cf8eb583c77600f282dc2ca5fd376c423..32a26899287440f54213d9bec2f871155f8ebd99 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 diff --git a/internal/llm/agent/agent-tool.go b/internal/llm/agent/agent-tool.go index 713b0690d03fd469f0aeb0f2a895d955fc82710b..781720ded69e625bed44eb5baa30b879b28e94ca 100644 --- a/internal/llm/agent/agent-tool.go +++ b/internal/llm/agent/agent-tool.go @@ -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 { diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index d669a4f58a389d98109bd2eb61deddda0c389892..03b2d59dd4359e91aa8b0fba2e99c02340f0b9f5 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -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 AgentEvent struct { - message message.Message - err error -} +type AgentEventType string -func (e *AgentEvent) Err() error { - return e.err -} +const ( + AgentEventTypeError AgentEventType = "error" + AgentEventTypeResponse AgentEventType = "response" + AgentEventTypeSummarize AgentEventType = "summarize" +) -func (e *AgentEvent) Response() message.Message { - return e.message +type AgentEvent struct { + Type AgentEventType + Message message.Message + Error error + + // 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] diff --git a/internal/llm/prompt/prompt.go b/internal/llm/prompt/prompt.go index 83ec7442ffbfebd4e70ab2c1d254679c07d5da78..8cdbdfc269cad3be04bd471d1d39756254541c74 100644 --- a/internal/llm/prompt/prompt.go +++ b/internal/llm/prompt/prompt.go @@ -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" } diff --git a/internal/llm/prompt/summarizer.go b/internal/llm/prompt/summarizer.go new file mode 100644 index 0000000000000000000000000000000000000000..cbdadecaecb56fba6c773e8db2aa0ad9963aa2fd --- /dev/null +++ b/internal/llm/prompt/summarizer.go @@ -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.` +} diff --git a/internal/tui/components/core/status.go b/internal/tui/components/core/status.go index 7b8a87231cb88eccbe551b1fccbe8d5f97d0e103..0dc227a80ebbbcdd8a4e3578a11b1fe6f64aca4a 100644 --- a/internal/tui/components/core/status.go +++ b/internal/tui/components/core/status.go @@ -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, diff --git a/internal/tui/components/dialog/filepicker.go b/internal/tui/components/dialog/filepicker.go index b62ac5cbdebcbe54e5027304326a53650fd780b2..3b9a0dc6c39a090e084b010a4ac5640f338a4836 100644 --- a/internal/tui/components/dialog/filepicker.go +++ b/internal/tui/components/dialog/filepicker.go @@ -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)) } diff --git a/internal/tui/components/dialog/permission.go b/internal/tui/components/dialog/permission.go index c8c34a570ea50e65d05e5cb666eea6f43bb41351..6c135098a7ade938e7bec1a69b4a3a6f5db79d6f 100644 --- a/internal/tui/components/dialog/permission.go +++ b/internal/tui/components/dialog/permission.go @@ -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). diff --git a/internal/tui/tui.go b/internal/tui/tui.go index e20aa90ceecefa79df6595766124e79d41e4c1e3..b6259892dd8fcbf8d43ef06b7ba5075495501a66 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -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 } diff --git a/scripts/check_hidden_chars.sh b/scripts/check_hidden_chars.sh new file mode 100755 index 0000000000000000000000000000000000000000..42f23e52867a5a20fc0be813bf9455cb3c368bd1 --- /dev/null +++ b/scripts/check_hidden_chars.sh @@ -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 \ No newline at end of file