on main help sign in
Context Window Warning (#152)
90084ceKujtim Hoxha
README.md+31−3
@@ -62,12 +62,29 @@ OpenCode looks for configuration in the following locations:
62- `$XDG_CONFIG_HOME/opencode/.opencode.json`
62- `$XDG_CONFIG_HOME/opencode/.opencode.json`
63- `./.opencode.json` (local directory)
63- `./.opencode.json` (local directory)
64
64
65+### Auto Compact Feature
66+
67+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:
68+
69+- Monitors token usage during your conversation
70+- Automatically triggers summarization when usage reaches 95% of the model's context window
71+- Creates a new session with the summary, allowing you to continue your work without losing context
72+- Helps prevent "out of context" errors that can occur with long conversations
73+
74+You can enable or disable this feature in your configuration file:
75+
76+```json
77+{
78+ "autoCompact": true // default is true
79+}
80+```
81+
65### Environment Variables
82### Environment Variables
66
83
67You can configure OpenCode using environment variables:
84You can configure OpenCode using environment variables:
68
85
69| Environment Variable | Purpose |
86| Environment Variable | Purpose |
70−|----------------------------|--------------------------------------------------------|
87+| -------------------------- | ------------------------------------------------------ |
71| `ANTHROPIC_API_KEY` | For Claude models |
88| `ANTHROPIC_API_KEY` | For Claude models |
72| `OPENAI_API_KEY` | For OpenAI models |
89| `OPENAI_API_KEY` | For OpenAI models |
73| `GEMINI_API_KEY` | For Google Gemini models |
90| `GEMINI_API_KEY` | For Google Gemini models |
@@ -79,7 +96,6 @@ You can configure OpenCode using environment variables:
79| `AZURE_OPENAI_API_KEY` | For Azure OpenAI models (optional when using Entra ID) |
96| `AZURE_OPENAI_API_KEY` | For Azure OpenAI models (optional when using Entra ID) |
80| `AZURE_OPENAI_API_VERSION` | For Azure OpenAI models |
97| `AZURE_OPENAI_API_VERSION` | For Azure OpenAI models |
81
98
82−
83### Configuration File Structure
99### Configuration File Structure
84
100
85```json
101```json
@@ -134,7 +150,8 @@ You can configure OpenCode using environment variables:
134 }
150 }
135 },
151 },
136 "debug": false,
152 "debug": false,
137− "debugLSP": false
153+ "debugLSP": false,
154+ "autoCompact": true
138}
155}
139```
156```
140
157
@@ -327,9 +344,11 @@ OpenCode supports custom commands that can be created by users to quickly send p
327Custom commands are predefined prompts stored as Markdown files in one of three locations:
344Custom commands are predefined prompts stored as Markdown files in one of three locations:
328
345
3291. **User Commands** (prefixed with `user:`):
3461. **User Commands** (prefixed with `user:`):
347+
330 ```
348 ```
331 $XDG_CONFIG_HOME/opencode/commands/
349 $XDG_CONFIG_HOME/opencode/commands/
332 ```
350 ```
351+
333 (typically `~/.config/opencode/commands/` on Linux/macOS)
352 (typically `~/.config/opencode/commands/` on Linux/macOS)
334
353
335 or
354 or
@@ -382,6 +401,15 @@ This creates a command with ID `user:git:commit`.
382
401
383The content of the command file will be sent as a message to the AI assistant.
402The content of the command file will be sent as a message to the AI assistant.
384
403
404+### Built-in Commands
405+
406+OpenCode includes several built-in commands:
407+
408+| Command | Description |
409+| ------------------ | --------------------------------------------------------------------------------------------------- |
410+| Initialize Project | Creates or updates the OpenCode.md memory file with project-specific information |
411+| Compact Session | Manually triggers the summarization of the current session, creating a new session with the summary |
412+
385## MCP (Model Context Protocol)
413## MCP (Model Context Protocol)
386
414
387OpenCode 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.
415OpenCode 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.
cmd/root.go+1
@@ -218,6 +218,7 @@ func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg,
218 setupSubscriber(ctx, &wg, "sessions", app.Sessions.Subscribe, ch)
218 setupSubscriber(ctx, &wg, "sessions", app.Sessions.Subscribe, ch)
219 setupSubscriber(ctx, &wg, "messages", app.Messages.Subscribe, ch)
219 setupSubscriber(ctx, &wg, "messages", app.Messages.Subscribe, ch)
220 setupSubscriber(ctx, &wg, "permissions", app.Permissions.Subscribe, ch)
220 setupSubscriber(ctx, &wg, "permissions", app.Permissions.Subscribe, ch)
221+ setupSubscriber(ctx, &wg, "coderAgent", app.CoderAgent.Subscribe, ch)
221
222
222 cleanupFunc := func() {
223 cleanupFunc := func() {
223 logging.Info("Cancelling all subscriptions")
224 logging.Info("Cancelling all subscriptions")
internal/config/config.go+14−3
@@ -36,9 +36,10 @@ type MCPServer struct {
36type AgentName string
36type AgentName string
37
37
38const (
38const (
39− AgentCoder AgentName = "coder"
39+ AgentCoder AgentName = "coder"
40+ AgentSummarizer AgentName = "summarizer"
40− AgentTask AgentName = "task"
41+ AgentTask AgentName = "task"
41− AgentTitle AgentName = "title"
42+ AgentTitle AgentName = "title"
42)
43)
43
44
44// Agent defines configuration for different LLM models and their token limits.
45// Agent defines configuration for different LLM models and their token limits.
@@ -84,6 +85,7 @@ type Config struct {
84 DebugLSP bool `json:"debugLSP,omitempty"`
85 DebugLSP bool `json:"debugLSP,omitempty"`
85 ContextPaths []string `json:"contextPaths,omitempty"`
86 ContextPaths []string `json:"contextPaths,omitempty"`
86 TUI TUIConfig `json:"tui"`
87 TUI TUIConfig `json:"tui"`
88+ AutoCompact bool `json:"autoCompact,omitempty"`
87}
89}
88
90
89// Application constants
91// Application constants
@@ -213,6 +215,7 @@ func setDefaults(debug bool) {
213 viper.SetDefault("data.directory", defaultDataDirectory)
215 viper.SetDefault("data.directory", defaultDataDirectory)
214 viper.SetDefault("contextPaths", defaultContextPaths)
216 viper.SetDefault("contextPaths", defaultContextPaths)
215 viper.SetDefault("tui.theme", "opencode")
217 viper.SetDefault("tui.theme", "opencode")
218+ viper.SetDefault("autoCompact", true)
216
219
217 if debug {
220 if debug {
218 viper.SetDefault("debug", true)
221 viper.SetDefault("debug", true)
@@ -262,6 +265,7 @@ func setProviderDefaults() {
262 // Anthropic configuration
265 // Anthropic configuration
263 if key := viper.GetString("providers.anthropic.apiKey"); strings.TrimSpace(key) != "" {
266 if key := viper.GetString("providers.anthropic.apiKey"); strings.TrimSpace(key) != "" {
264 viper.SetDefault("agents.coder.model", models.Claude37Sonnet)
267 viper.SetDefault("agents.coder.model", models.Claude37Sonnet)
268+ viper.SetDefault("agents.summarizer.model", models.Claude37Sonnet)
265 viper.SetDefault("agents.task.model", models.Claude37Sonnet)
269 viper.SetDefault("agents.task.model", models.Claude37Sonnet)
266 viper.SetDefault("agents.title.model", models.Claude37Sonnet)
270 viper.SetDefault("agents.title.model", models.Claude37Sonnet)
267 return
271 return
@@ -270,6 +274,7 @@ func setProviderDefaults() {
270 // OpenAI configuration
274 // OpenAI configuration
271 if key := viper.GetString("providers.openai.apiKey"); strings.TrimSpace(key) != "" {
275 if key := viper.GetString("providers.openai.apiKey"); strings.TrimSpace(key) != "" {
272 viper.SetDefault("agents.coder.model", models.GPT41)
276 viper.SetDefault("agents.coder.model", models.GPT41)
277+ viper.SetDefault("agents.summarizer.model", models.GPT41)
273 viper.SetDefault("agents.task.model", models.GPT41Mini)
278 viper.SetDefault("agents.task.model", models.GPT41Mini)
274 viper.SetDefault("agents.title.model", models.GPT41Mini)
279 viper.SetDefault("agents.title.model", models.GPT41Mini)
275 return
280 return
@@ -278,6 +283,7 @@ func setProviderDefaults() {
278 // Google Gemini configuration
283 // Google Gemini configuration
279 if key := viper.GetString("providers.gemini.apiKey"); strings.TrimSpace(key) != "" {
284 if key := viper.GetString("providers.gemini.apiKey"); strings.TrimSpace(key) != "" {
280 viper.SetDefault("agents.coder.model", models.Gemini25)
285 viper.SetDefault("agents.coder.model", models.Gemini25)
286+ viper.SetDefault("agents.summarizer.model", models.Gemini25)
281 viper.SetDefault("agents.task.model", models.Gemini25Flash)
287 viper.SetDefault("agents.task.model", models.Gemini25Flash)
282 viper.SetDefault("agents.title.model", models.Gemini25Flash)
288 viper.SetDefault("agents.title.model", models.Gemini25Flash)
283 return
289 return
@@ -286,6 +292,7 @@ func setProviderDefaults() {
286 // Groq configuration
292 // Groq configuration
287 if key := viper.GetString("providers.groq.apiKey"); strings.TrimSpace(key) != "" {
293 if key := viper.GetString("providers.groq.apiKey"); strings.TrimSpace(key) != "" {
288 viper.SetDefault("agents.coder.model", models.QWENQwq)
294 viper.SetDefault("agents.coder.model", models.QWENQwq)
295+ viper.SetDefault("agents.summarizer.model", models.QWENQwq)
289 viper.SetDefault("agents.task.model", models.QWENQwq)
296 viper.SetDefault("agents.task.model", models.QWENQwq)
290 viper.SetDefault("agents.title.model", models.QWENQwq)
297 viper.SetDefault("agents.title.model", models.QWENQwq)
291 return
298 return
@@ -294,6 +301,7 @@ func setProviderDefaults() {
294 // OpenRouter configuration
301 // OpenRouter configuration
295 if key := viper.GetString("providers.openrouter.apiKey"); strings.TrimSpace(key) != "" {
302 if key := viper.GetString("providers.openrouter.apiKey"); strings.TrimSpace(key) != "" {
296 viper.SetDefault("agents.coder.model", models.OpenRouterClaude37Sonnet)
303 viper.SetDefault("agents.coder.model", models.OpenRouterClaude37Sonnet)
304+ viper.SetDefault("agents.summarizer.model", models.OpenRouterClaude37Sonnet)
297 viper.SetDefault("agents.task.model", models.OpenRouterClaude37Sonnet)
305 viper.SetDefault("agents.task.model", models.OpenRouterClaude37Sonnet)
298 viper.SetDefault("agents.title.model", models.OpenRouterClaude35Haiku)
306 viper.SetDefault("agents.title.model", models.OpenRouterClaude35Haiku)
299 return
307 return
@@ -302,6 +310,7 @@ func setProviderDefaults() {
302 // XAI configuration
310 // XAI configuration
303 if key := viper.GetString("providers.xai.apiKey"); strings.TrimSpace(key) != "" {
311 if key := viper.GetString("providers.xai.apiKey"); strings.TrimSpace(key) != "" {
304 viper.SetDefault("agents.coder.model", models.XAIGrok3Beta)
312 viper.SetDefault("agents.coder.model", models.XAIGrok3Beta)
313+ viper.SetDefault("agents.summarizer.model", models.XAIGrok3Beta)
305 viper.SetDefault("agents.task.model", models.XAIGrok3Beta)
314 viper.SetDefault("agents.task.model", models.XAIGrok3Beta)
306 viper.SetDefault("agents.title.model", models.XAiGrok3MiniFastBeta)
315 viper.SetDefault("agents.title.model", models.XAiGrok3MiniFastBeta)
307 return
316 return
@@ -310,6 +319,7 @@ func setProviderDefaults() {
310 // AWS Bedrock configuration
319 // AWS Bedrock configuration
311 if hasAWSCredentials() {
320 if hasAWSCredentials() {
312 viper.SetDefault("agents.coder.model", models.BedrockClaude37Sonnet)
321 viper.SetDefault("agents.coder.model", models.BedrockClaude37Sonnet)
322+ viper.SetDefault("agents.summarizer.model", models.BedrockClaude37Sonnet)
313 viper.SetDefault("agents.task.model", models.BedrockClaude37Sonnet)
323 viper.SetDefault("agents.task.model", models.BedrockClaude37Sonnet)
314 viper.SetDefault("agents.title.model", models.BedrockClaude37Sonnet)
324 viper.SetDefault("agents.title.model", models.BedrockClaude37Sonnet)
315 return
325 return
@@ -318,6 +328,7 @@ func setProviderDefaults() {
318 // Azure OpenAI configuration
328 // Azure OpenAI configuration
319 if os.Getenv("AZURE_OPENAI_ENDPOINT") != "" {
329 if os.Getenv("AZURE_OPENAI_ENDPOINT") != "" {
320 viper.SetDefault("agents.coder.model", models.AzureGPT41)
330 viper.SetDefault("agents.coder.model", models.AzureGPT41)
331+ viper.SetDefault("agents.summarizer.model", models.AzureGPT41)
321 viper.SetDefault("agents.task.model", models.AzureGPT41Mini)
332 viper.SetDefault("agents.task.model", models.AzureGPT41Mini)
322 viper.SetDefault("agents.title.model", models.AzureGPT41Mini)
333 viper.SetDefault("agents.title.model", models.AzureGPT41Mini)
323 return
334 return
internal/llm/agent/agent-tool.go+3−5
@@ -69,11 +69,11 @@ func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolRes
69 return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", err)
69 return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", err)
70 }
70 }
71 result := <-done
71 result := <-done
72− if result.Err() != nil {
72+ if result.Error != nil {
73− return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", result.Err())
73+ return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", result.Error)
74 }
74 }
75
75
76− response := result.Response()
76+ response := result.Message
77 if response.Role != message.Assistant {
77 if response.Role != message.Assistant {
78 return tools.NewTextErrorResponse("no response"), nil
78 return tools.NewTextErrorResponse("no response"), nil
79 }
79 }
@@ -88,8 +88,6 @@ func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolRes
88 }
88 }
89
89
90 parentSession.Cost += updatedSession.Cost
90 parentSession.Cost += updatedSession.Cost
91− parentSession.PromptTokens += updatedSession.PromptTokens
92− parentSession.CompletionTokens += updatedSession.CompletionTokens
93
91
94 _, err = b.sessions.Save(ctx, parentSession)
92 _, err = b.sessions.Save(ctx, parentSession)
95 if err != nil {
93 if err != nil {
internal/llm/agent/agent.go+216−22
@@ -15,6 +15,7 @@ import (
15 "github.com/opencode-ai/opencode/internal/logging"
15 "github.com/opencode-ai/opencode/internal/logging"
16 "github.com/opencode-ai/opencode/internal/message"
16 "github.com/opencode-ai/opencode/internal/message"
17 "github.com/opencode-ai/opencode/internal/permission"
17 "github.com/opencode-ai/opencode/internal/permission"
18+ "github.com/opencode-ai/opencode/internal/pubsub"
18 "github.com/opencode-ai/opencode/internal/session"
19 "github.com/opencode-ai/opencode/internal/session"
19)
20)
20
21
@@ -24,35 +25,46 @@ var (
24 ErrSessionBusy = errors.New("session is currently processing another request")
25 ErrSessionBusy = errors.New("session is currently processing another request")
25)
26)
26
27
27−type AgentEvent struct {
28+type AgentEventType string
28− message message.Message
29− err error
30−}
31
29
32−func (e *AgentEvent) Err() error {
30+const (
33− return e.err
31+ AgentEventTypeError AgentEventType = "error"
34−}
32+ AgentEventTypeResponse AgentEventType = "response"
33+ AgentEventTypeSummarize AgentEventType = "summarize"
34+)
35
35
36−func (e *AgentEvent) Response() message.Message {
36+type AgentEvent struct {
37− return e.message
37+ Type AgentEventType
38+ Message message.Message
39+ Error error
40+
41+ // When summarizing
42+ SessionID string
43+ Progress string
44+ Done bool
38}
45}
39
46
40type Service interface {
47type Service interface {
48+ pubsub.Suscriber[AgentEvent]
49+ Model() models.Model
41 Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
50 Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
42 Cancel(sessionID string)
51 Cancel(sessionID string)
43 IsSessionBusy(sessionID string) bool
52 IsSessionBusy(sessionID string) bool
44 IsBusy() bool
53 IsBusy() bool
45 Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error)
54 Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error)
55+ Summarize(ctx context.Context, sessionID string) error
46}
56}
47
57
48type agent struct {
58type agent struct {
59+ *pubsub.Broker[AgentEvent]
49 sessions session.Service
60 sessions session.Service
50 messages message.Service
61 messages message.Service
51
62
52 tools []tools.BaseTool
63 tools []tools.BaseTool
53 provider provider.Provider
64 provider provider.Provider
54
65
66+ titleProvider provider.Provider
55− titleProvider provider.Provider
67+ summarizeProvider provider.Provider
56
68
57 activeRequests sync.Map
69 activeRequests sync.Map
58}
70}
@@ -75,26 +87,48 @@ func NewAgent(
75 return nil, err
87 return nil, err
76 }
88 }
77 }
89 }
90+ var summarizeProvider provider.Provider
91+ if agentName == config.AgentCoder {
92+ summarizeProvider, err = createAgentProvider(config.AgentSummarizer)
93+ if err != nil {
94+ return nil, err
95+ }
96+ }
78
97
79 agent := &agent{
98 agent := &agent{
99+ Broker: pubsub.NewBroker[AgentEvent](),
80− provider: agentProvider,
100+ provider: agentProvider,
81− messages: messages,
101+ messages: messages,
82− sessions: sessions,
102+ sessions: sessions,
83− tools: agentTools,
103+ tools: agentTools,
84− titleProvider: titleProvider,
104+ titleProvider: titleProvider,
105+ summarizeProvider: summarizeProvider,
85− activeRequests: sync.Map{},
106+ activeRequests: sync.Map{},
86 }
107 }
87
108
88 return agent, nil
109 return agent, nil
89}
110}
90
111
112+func (a *agent) Model() models.Model {
113+ return a.provider.Model()
114+}
115+
91func (a *agent) Cancel(sessionID string) {
116func (a *agent) Cancel(sessionID string) {
117+ // Cancel regular requests
92 if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID); exists {
118 if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID); exists {
93 if cancel, ok := cancelFunc.(context.CancelFunc); ok {
119 if cancel, ok := cancelFunc.(context.CancelFunc); ok {
94 logging.InfoPersist(fmt.Sprintf("Request cancellation initiated for session: %s", sessionID))
120 logging.InfoPersist(fmt.Sprintf("Request cancellation initiated for session: %s", sessionID))
95 cancel()
121 cancel()
96 }
122 }
97 }
123 }
124+
125+ // Also check for summarize requests
126+ if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID + "-summarize"); exists {
127+ if cancel, ok := cancelFunc.(context.CancelFunc); ok {
128+ logging.InfoPersist(fmt.Sprintf("Summarize cancellation initiated for session: %s", sessionID))
129+ cancel()
130+ }
131+ }
98}
132}
99
133
100func (a *agent) IsBusy() bool {
134func (a *agent) IsBusy() bool {
@@ -154,7 +188,8 @@ func (a *agent) generateTitle(ctx context.Context, sessionID string, content str
154
188
155func (a *agent) err(err error) AgentEvent {
189func (a *agent) err(err error) AgentEvent {
156 return AgentEvent{
190 return AgentEvent{
191+ Type: AgentEventTypeError,
157− err: err,
192+ Error: err,
158 }
193 }
159}
194}
160
195
@@ -180,12 +215,13 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
180 attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
215 attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
181 }
216 }
182 result := a.processGeneration(genCtx, sessionID, content, attachmentParts)
217 result := a.processGeneration(genCtx, sessionID, content, attachmentParts)
183− if result.Err() != nil && !errors.Is(result.Err(), ErrRequestCancelled) && !errors.Is(result.Err(), context.Canceled) {
218+ if result.Error != nil && !errors.Is(result.Error, ErrRequestCancelled) && !errors.Is(result.Error, context.Canceled) {
184− logging.ErrorPersist(result.Err().Error())
219+ logging.ErrorPersist(result.Error.Error())
185 }
220 }
186 logging.Debug("Request completed", "sessionID", sessionID)
221 logging.Debug("Request completed", "sessionID", sessionID)
187 a.activeRequests.Delete(sessionID)
222 a.activeRequests.Delete(sessionID)
188 cancel()
223 cancel()
224+ a.Publish(pubsub.CreatedEvent, result)
189 events <- result
225 events <- result
190 close(events)
226 close(events)
191 }()
227 }()
@@ -241,7 +277,9 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
241 continue
277 continue
242 }
278 }
243 return AgentEvent{
279 return AgentEvent{
280+ Type: AgentEventTypeResponse,
244− message: agentMessage,
281+ Message: agentMessage,
282+ Done: true,
245 }
283 }
246 }
284 }
247}
285}
@@ -432,8 +470,8 @@ func (a *agent) TrackUsage(ctx context.Context, sessionID string, model models.M
432 model.CostPer1MOut/1e6*float64(usage.OutputTokens)
470 model.CostPer1MOut/1e6*float64(usage.OutputTokens)
433
471
434 sess.Cost += cost
472 sess.Cost += cost
435− sess.CompletionTokens += usage.OutputTokens
473+ sess.CompletionTokens = usage.OutputTokens + usage.CacheReadTokens
436− sess.PromptTokens += usage.InputTokens
474+ sess.PromptTokens = usage.InputTokens + usage.CacheCreationTokens
437
475
438 _, err = a.sessions.Save(ctx, sess)
476 _, err = a.sessions.Save(ctx, sess)
439 if err != nil {
477 if err != nil {
@@ -461,6 +499,162 @@ func (a *agent) Update(agentName config.AgentName, modelID models.ModelID) (mode
461 return a.provider.Model(), nil
499 return a.provider.Model(), nil
462}
500}
463
501
502+func (a *agent) Summarize(ctx context.Context, sessionID string) error {
503+ if a.summarizeProvider == nil {
504+ return fmt.Errorf("summarize provider not available")
505+ }
506+
507+ // Check if session is busy
508+ if a.IsSessionBusy(sessionID) {
509+ return ErrSessionBusy
510+ }
511+
512+ // Create a new context with cancellation
513+ summarizeCtx, cancel := context.WithCancel(ctx)
514+
515+ // Store the cancel function in activeRequests to allow cancellation
516+ a.activeRequests.Store(sessionID+"-summarize", cancel)
517+
518+ go func() {
519+ defer a.activeRequests.Delete(sessionID + "-summarize")
520+ defer cancel()
521+ event := AgentEvent{
522+ Type: AgentEventTypeSummarize,
523+ Progress: "Starting summarization...",
524+ }
525+
526+ a.Publish(pubsub.CreatedEvent, event)
527+ // Get all messages from the session
528+ msgs, err := a.messages.List(summarizeCtx, sessionID)
529+ if err != nil {
530+ event = AgentEvent{
531+ Type: AgentEventTypeError,
532+ Error: fmt.Errorf("failed to list messages: %w", err),
533+ Done: true,
534+ }
535+ a.Publish(pubsub.CreatedEvent, event)
536+ return
537+ }
538+
539+ if len(msgs) == 0 {
540+ event = AgentEvent{
541+ Type: AgentEventTypeError,
542+ Error: fmt.Errorf("no messages to summarize"),
543+ Done: true,
544+ }
545+ a.Publish(pubsub.CreatedEvent, event)
546+ return
547+ }
548+
549+ event = AgentEvent{
550+ Type: AgentEventTypeSummarize,
551+ Progress: "Analyzing conversation...",
552+ }
553+ a.Publish(pubsub.CreatedEvent, event)
554+
555+ // Add a system message to guide the summarization
556+ 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."
557+
558+ // Create a new message with the summarize prompt
559+ promptMsg := message.Message{
560+ Role: message.User,
561+ Parts: []message.ContentPart{message.TextContent{Text: summarizePrompt}},
562+ }
563+
564+ // Append the prompt to the messages
565+ msgsWithPrompt := append(msgs, promptMsg)
566+
567+ event = AgentEvent{
568+ Type: AgentEventTypeSummarize,
569+ Progress: "Generating summary...",
570+ }
571+
572+ a.Publish(pubsub.CreatedEvent, event)
573+
574+ // Send the messages to the summarize provider
575+ response, err := a.summarizeProvider.SendMessages(
576+ summarizeCtx,
577+ msgsWithPrompt,
578+ make([]tools.BaseTool, 0),
579+ )
580+ if err != nil {
581+ event = AgentEvent{
582+ Type: AgentEventTypeError,
583+ Error: fmt.Errorf("failed to summarize: %w", err),
584+ Done: true,
585+ }
586+ a.Publish(pubsub.CreatedEvent, event)
587+ return
588+ }
589+
590+ summary := strings.TrimSpace(response.Content)
591+ if summary == "" {
592+ event = AgentEvent{
593+ Type: AgentEventTypeError,
594+ Error: fmt.Errorf("empty summary returned"),
595+ Done: true,
596+ }
597+ a.Publish(pubsub.CreatedEvent, event)
598+ return
599+ }
600+ event = AgentEvent{
601+ Type: AgentEventTypeSummarize,
602+ Progress: "Creating new session...",
603+ }
604+
605+ a.Publish(pubsub.CreatedEvent, event)
606+ oldSession, err := a.sessions.Get(summarizeCtx, sessionID)
607+ if err != nil {
608+ event = AgentEvent{
609+ Type: AgentEventTypeError,
610+ Error: fmt.Errorf("failed to get session: %w", err),
611+ Done: true,
612+ }
613+
614+ a.Publish(pubsub.CreatedEvent, event)
615+ return
616+ }
617+ // Create a new session with the summary
618+ newSession, err := a.sessions.Create(summarizeCtx, oldSession.Title+" - Continuation")
619+ if err != nil {
620+ event = AgentEvent{
621+ Type: AgentEventTypeError,
622+ Error: fmt.Errorf("failed to create new session: %w", err),
623+ Done: true,
624+ }
625+ a.Publish(pubsub.CreatedEvent, event)
626+ return
627+ }
628+
629+ // Create a message in the new session with the summary
630+ _, err = a.messages.Create(summarizeCtx, newSession.ID, message.CreateMessageParams{
631+ Role: message.Assistant,
632+ Parts: []message.ContentPart{message.TextContent{Text: summary}},
633+ Model: a.summarizeProvider.Model().ID,
634+ })
635+ if err != nil {
636+ event = AgentEvent{
637+ Type: AgentEventTypeError,
638+ Error: fmt.Errorf("failed to create summary message: %w", err),
639+ Done: true,
640+ }
641+
642+ a.Publish(pubsub.CreatedEvent, event)
643+ return
644+ }
645+ event = AgentEvent{
646+ Type: AgentEventTypeSummarize,
647+ SessionID: newSession.ID,
648+ Progress: "Summary complete",
649+ Done: true,
650+ }
651+ a.Publish(pubsub.CreatedEvent, event)
652+ // Send final success event with the new session ID
653+ }()
654+
655+ return nil
656+}
657+
464func createAgentProvider(agentName config.AgentName) (provider.Provider, error) {
658func createAgentProvider(agentName config.AgentName) (provider.Provider, error) {
465 cfg := config.Get()
659 cfg := config.Get()
466 agentConfig, ok := cfg.Agents[agentName]
660 agentConfig, ok := cfg.Agents[agentName]
internal/llm/prompt/prompt.go+2
@@ -21,6 +21,8 @@ func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) s
21 basePrompt = TitlePrompt(provider)
21 basePrompt = TitlePrompt(provider)
22 case config.AgentTask:
22 case config.AgentTask:
23 basePrompt = TaskPrompt(provider)
23 basePrompt = TaskPrompt(provider)
24+ case config.AgentSummarizer:
25+ basePrompt = SummarizerPrompt(provider)
24 default:
26 default:
25 basePrompt = "You are a helpful assistant"
27 basePrompt = "You are a helpful assistant"
26 }
28 }
internal/llm/prompt/summarizer.gonew file+16
@@ -0,0 +1,16 @@
1+package prompt
2+
3+import "github.com/opencode-ai/opencode/internal/llm/models"
4+
5+func SummarizerPrompt(_ models.ModelProvider) string {
6+ return `You are a helpful AI assistant tasked with summarizing conversations.
7+
8+When asked to summarize, provide a detailed but concise summary of the conversation.
9+Focus on information that would be helpful for continuing the conversation, including:
10+- What was done
11+- What is currently being worked on
12+- Which files are being modified
13+- What needs to be done next
14+
15+Your summary should be comprehensive enough to provide context but concise enough to be quickly understood.`
16+}
internal/tui/components/core/status.go+33−28
@@ -21,7 +21,6 @@ import (
21
21
22type StatusCmp interface {
22type StatusCmp interface {
23 tea.Model
23 tea.Model
24− SetHelpWidgetMsg(string)
25}
24}
26
25
27type statusCmp struct {
26type statusCmp struct {
@@ -74,11 +73,9 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
74var helpWidget = ""
73var helpWidget = ""
75
74
76// getHelpWidget returns the help widget with current theme colors
75// getHelpWidget returns the help widget with current theme colors
77−func getHelpWidget(helpText string) string {
76+func getHelpWidget() string {
78 t := theme.CurrentTheme()
77 t := theme.CurrentTheme()
79− if helpText == "" {
78+ helpText := "ctrl+? help"
80− helpText = "ctrl+? help"
81− }
82
79
83 return styles.Padded().
80 return styles.Padded().
84 Background(t.TextMuted()).
81 Background(t.TextMuted()).
@@ -87,7 +84,7 @@ func getHelpWidget(helpText string) string {
87 Render(helpText)
84 Render(helpText)
88}
85}
89
86
90−func formatTokensAndCost(tokens int64, cost float64) string {
87+func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
91 // Format tokens in human-readable format (e.g., 110K, 1.2M)
88 // Format tokens in human-readable format (e.g., 110K, 1.2M)
92 var formattedTokens string
89 var formattedTokens string
93 switch {
90 switch {
@@ -110,32 +107,48 @@ func formatTokensAndCost(tokens int64, cost float64) string {
110 // Format cost with $ symbol and 2 decimal places
107 // Format cost with $ symbol and 2 decimal places
111 formattedCost := fmt.Sprintf("$%.2f", cost)
108 formattedCost := fmt.Sprintf("$%.2f", cost)
112
109
110+ percentage := (float64(tokens) / float64(contextWindow)) * 100
111+ if percentage > 80 {
112+ // add the warning icon and percentage
113+ formattedTokens = fmt.Sprintf("%s(%d%%)", styles.WarningIcon, int(percentage))
114+ }
115+
113− return fmt.Sprintf("Tokens: %s, Cost: %s", formattedTokens, formattedCost)
116+ return fmt.Sprintf("Context: %s, Cost: %s", formattedTokens, formattedCost)
114}
117}
115
118
116func (m statusCmp) View() string {
119func (m statusCmp) View() string {
117 t := theme.CurrentTheme()
120 t := theme.CurrentTheme()
121+ modelID := config.Get().Agents[config.AgentCoder].Model
122+ model := models.SupportedModels[modelID]
118
123
119 // Initialize the help widget
124 // Initialize the help widget
120− status := getHelpWidget("")
125+ status := getHelpWidget()
121
126
127+ tokenInfoWidth := 0
122 if m.session.ID != "" {
128 if m.session.ID != "" {
129+ totalTokens := m.session.PromptTokens + m.session.CompletionTokens
123− tokens := formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
130+ tokens := formatTokensAndCost(totalTokens, model.ContextWindow, m.session.Cost)
124 tokensStyle := styles.Padded().
131 tokensStyle := styles.Padded().
125 Background(t.Text()).
132 Background(t.Text()).
126− Foreground(t.BackgroundSecondary()).
133+ Foreground(t.BackgroundSecondary())
127− Render(tokens)
134+ percentage := (float64(totalTokens) / float64(model.ContextWindow)) * 100
135+ if percentage > 80 {
136+ tokensStyle = tokensStyle.Background(t.Warning())
137+ }
138+ tokenInfoWidth = lipgloss.Width(tokens) + 2
128− status += tokensStyle
139+ status += tokensStyle.Render(tokens)
129 }
140 }
130
141
131 diagnostics := styles.Padded().
142 diagnostics := styles.Padded().
132 Background(t.BackgroundDarker()).
143 Background(t.BackgroundDarker()).
133 Render(m.projectDiagnostics())
144 Render(m.projectDiagnostics())
134
145
146+ availableWidht := max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokenInfoWidth)
147+
135 if m.info.Msg != "" {
148 if m.info.Msg != "" {
136 infoStyle := styles.Padded().
149 infoStyle := styles.Padded().
137 Foreground(t.Background()).
150 Foreground(t.Background()).
138− Width(m.availableFooterMsgWidth(diagnostics))
151+ Width(availableWidht)
139
152
140 switch m.info.Type {
153 switch m.info.Type {
141 case util.InfoTypeInfo:
154 case util.InfoTypeInfo:
@@ -146,18 +159,18 @@ func (m statusCmp) View() string {
146 infoStyle = infoStyle.Background(t.Error())
159 infoStyle = infoStyle.Background(t.Error())
147 }
160 }
148
161
162+ infoWidth := availableWidht - 10
149 // Truncate message if it's longer than available width
163 // Truncate message if it's longer than available width
150 msg := m.info.Msg
164 msg := m.info.Msg
151− availWidth := m.availableFooterMsgWidth(diagnostics) - 10
165+ if len(msg) > infoWidth && infoWidth > 0 {
152− if len(msg) > availWidth && availWidth > 0 {
166+ msg = msg[:infoWidth] + "..."
153− msg = msg[:availWidth] + "..."
154 }
167 }
155 status += infoStyle.Render(msg)
168 status += infoStyle.Render(msg)
156 } else {
169 } else {
157 status += styles.Padded().
170 status += styles.Padded().
158 Foreground(t.Text()).
171 Foreground(t.Text()).
159 Background(t.BackgroundSecondary()).
172 Background(t.BackgroundSecondary()).
160− Width(m.availableFooterMsgWidth(diagnostics)).
173+ Width(availableWidht).
161 Render("")
174 Render("")
162 }
175 }
163
176
@@ -245,12 +258,10 @@ func (m *statusCmp) projectDiagnostics() string {
245 return strings.Join(diagnostics, " ")
258 return strings.Join(diagnostics, " ")
246}
259}
247
260
248−func (m statusCmp) availableFooterMsgWidth(diagnostics string) int {
261+func (m statusCmp) availableFooterMsgWidth(diagnostics, tokenInfo string) int {
249− tokens := ""
250 tokensWidth := 0
262 tokensWidth := 0
251 if m.session.ID != "" {
263 if m.session.ID != "" {
252− tokens = formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
253− tokensWidth = lipgloss.Width(tokens) + 2
264+ tokensWidth = lipgloss.Width(tokenInfo) + 2
254 }
265 }
255 return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokensWidth)
266 return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokensWidth)
256}
267}
@@ -272,14 +283,8 @@ func (m statusCmp) model() string {
272 Render(model.Name)
283 Render(model.Name)
273}
284}
274
285
275−func (m statusCmp) SetHelpWidgetMsg(s string) {
276− // Update the help widget text using the getHelpWidget function
277− helpWidget = getHelpWidget(s)
278−}
279−
280func NewStatusCmp(lspClients map[string]*lsp.Client) StatusCmp {
286func NewStatusCmp(lspClients map[string]*lsp.Client) StatusCmp {
281− // Initialize the help widget with default text
282− helpWidget = getHelpWidget("")
287+ helpWidget = getHelpWidget()
283
288
284 return &statusCmp{
289 return &statusCmp{
285 messageTTL: 10 * time.Second,
290 messageTTL: 10 * time.Second,
internal/tui/components/dialog/filepicker.go+1−4
@@ -302,11 +302,8 @@ func (f *filepickerCmp) View() string {
302 }
302 }
303 if file.IsDir() {
303 if file.IsDir() {
304 filename = filename + "/"
304 filename = filename + "/"
305− } else if isExtSupported(file.Name()) {
306− filename = filename
307− } else {
308− filename = filename
309 }
305 }
306+ // No need to reassign filename if it's not changing
310
307
311 files = append(files, itemStyle.Padding(0, 1).Render(filename))
308 files = append(files, itemStyle.Padding(0, 1).Render(filename))
312 }
309 }
internal/tui/components/dialog/permission.go+40−12
@@ -2,6 +2,8 @@ package dialog
2
2
3import (
3import (
4 "fmt"
4 "fmt"
5+ "strings"
6+
5 "github.com/charmbracelet/bubbles/key"
7 "github.com/charmbracelet/bubbles/key"
6 "github.com/charmbracelet/bubbles/viewport"
8 "github.com/charmbracelet/bubbles/viewport"
7 tea "github.com/charmbracelet/bubbletea"
9 tea "github.com/charmbracelet/bubbletea"
@@ -13,7 +15,6 @@ import (
13 "github.com/opencode-ai/opencode/internal/tui/styles"
15 "github.com/opencode-ai/opencode/internal/tui/styles"
14 "github.com/opencode-ai/opencode/internal/tui/theme"
16 "github.com/opencode-ai/opencode/internal/tui/theme"
15 "github.com/opencode-ai/opencode/internal/tui/util"
17 "github.com/opencode-ai/opencode/internal/tui/util"
16− "strings"
17)
18)
18
19
19type PermissionAction string
20type PermissionAction string
@@ -150,7 +151,7 @@ func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
150func (p *permissionDialogCmp) renderButtons() string {
151func (p *permissionDialogCmp) renderButtons() string {
151 t := theme.CurrentTheme()
152 t := theme.CurrentTheme()
152 baseStyle := styles.BaseStyle()
153 baseStyle := styles.BaseStyle()
153−
154+
154 allowStyle := baseStyle
155 allowStyle := baseStyle
155 allowSessionStyle := baseStyle
156 allowSessionStyle := baseStyle
156 denyStyle := baseStyle
157 denyStyle := baseStyle
@@ -196,7 +197,7 @@ func (p *permissionDialogCmp) renderButtons() string {
196func (p *permissionDialogCmp) renderHeader() string {
197func (p *permissionDialogCmp) renderHeader() string {
197 t := theme.CurrentTheme()
198 t := theme.CurrentTheme()
198 baseStyle := styles.BaseStyle()
199 baseStyle := styles.BaseStyle()
199−
200+
200 toolKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Tool")
201 toolKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Tool")
201 toolValue := baseStyle.
202 toolValue := baseStyle.
202 Foreground(t.Text()).
203 Foreground(t.Text()).
@@ -229,9 +230,36 @@ func (p *permissionDialogCmp) renderHeader() string {
229 case tools.BashToolName:
230 case tools.BashToolName:
230 headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Command"))
231 headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Command"))
231 case tools.EditToolName:
232 case tools.EditToolName:
233+ params := p.permission.Params.(tools.EditPermissionsParams)
232− headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Diff"))
234+ fileKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("File")
235+ filePath := baseStyle.
236+ Foreground(t.Text()).
237+ Width(p.width - lipgloss.Width(fileKey)).
238+ Render(fmt.Sprintf(": %s", params.FilePath))
239+ headerParts = append(headerParts,
240+ lipgloss.JoinHorizontal(
241+ lipgloss.Left,
242+ fileKey,
243+ filePath,
244+ ),
245+ baseStyle.Render(strings.Repeat(" ", p.width)),
246+ )
247+
233 case tools.WriteToolName:
248 case tools.WriteToolName:
249+ params := p.permission.Params.(tools.WritePermissionsParams)
234− headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Diff"))
250+ fileKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("File")
251+ filePath := baseStyle.
252+ Foreground(t.Text()).
253+ Width(p.width - lipgloss.Width(fileKey)).
254+ Render(fmt.Sprintf(": %s", params.FilePath))
255+ headerParts = append(headerParts,
256+ lipgloss.JoinHorizontal(
257+ lipgloss.Left,
258+ fileKey,
259+ filePath,
260+ ),
261+ baseStyle.Render(strings.Repeat(" ", p.width)),
262+ )
235 case tools.FetchToolName:
263 case tools.FetchToolName:
236 headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("URL"))
264 headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("URL"))
237 }
265 }
@@ -242,13 +270,13 @@ func (p *permissionDialogCmp) renderHeader() string {
242func (p *permissionDialogCmp) renderBashContent() string {
270func (p *permissionDialogCmp) renderBashContent() string {
243 t := theme.CurrentTheme()
271 t := theme.CurrentTheme()
244 baseStyle := styles.BaseStyle()
272 baseStyle := styles.BaseStyle()
245−
273+
246 if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
274 if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
247 content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
275 content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
248
276
249 // Use the cache for markdown rendering
277 // Use the cache for markdown rendering
250 renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
278 renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
251− r := styles.GetMarkdownRenderer(p.width-10)
279+ r := styles.GetMarkdownRenderer(p.width - 10)
252 s, err := r.Render(content)
280 s, err := r.Render(content)
253 return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
281 return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
254 })
282 })
@@ -302,13 +330,13 @@ func (p *permissionDialogCmp) renderWriteContent() string {
302func (p *permissionDialogCmp) renderFetchContent() string {
330func (p *permissionDialogCmp) renderFetchContent() string {
303 t := theme.CurrentTheme()
331 t := theme.CurrentTheme()
304 baseStyle := styles.BaseStyle()
332 baseStyle := styles.BaseStyle()
305−
333+
306 if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
334 if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
307 content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
335 content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
308
336
309 // Use the cache for markdown rendering
337 // Use the cache for markdown rendering
310 renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
338 renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
311− r := styles.GetMarkdownRenderer(p.width-10)
339+ r := styles.GetMarkdownRenderer(p.width - 10)
312 s, err := r.Render(content)
340 s, err := r.Render(content)
313 return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
341 return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
314 })
342 })
@@ -325,12 +353,12 @@ func (p *permissionDialogCmp) renderFetchContent() string {
325func (p *permissionDialogCmp) renderDefaultContent() string {
353func (p *permissionDialogCmp) renderDefaultContent() string {
326 t := theme.CurrentTheme()
354 t := theme.CurrentTheme()
327 baseStyle := styles.BaseStyle()
355 baseStyle := styles.BaseStyle()
328−
356+
329 content := p.permission.Description
357 content := p.permission.Description
330
358
331 // Use the cache for markdown rendering
359 // Use the cache for markdown rendering
332 renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
360 renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
333− r := styles.GetMarkdownRenderer(p.width-10)
361+ r := styles.GetMarkdownRenderer(p.width - 10)
334 s, err := r.Render(content)
362 s, err := r.Render(content)
335 return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
363 return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
336 })
364 })
@@ -358,7 +386,7 @@ func (p *permissionDialogCmp) styleViewport() string {
358func (p *permissionDialogCmp) render() string {
386func (p *permissionDialogCmp) render() string {
359 t := theme.CurrentTheme()
387 t := theme.CurrentTheme()
360 baseStyle := styles.BaseStyle()
388 baseStyle := styles.BaseStyle()
361−
389+
362 title := baseStyle.
390 title := baseStyle.
363 Bold(true).
391 Bold(true).
364 Width(p.width - 4).
392 Width(p.width - 4).
internal/tui/tui.go+139−21
@@ -10,14 +10,17 @@ import (
10 "github.com/charmbracelet/lipgloss"
10 "github.com/charmbracelet/lipgloss"
11 "github.com/opencode-ai/opencode/internal/app"
11 "github.com/opencode-ai/opencode/internal/app"
12 "github.com/opencode-ai/opencode/internal/config"
12 "github.com/opencode-ai/opencode/internal/config"
13+ "github.com/opencode-ai/opencode/internal/llm/agent"
13 "github.com/opencode-ai/opencode/internal/logging"
14 "github.com/opencode-ai/opencode/internal/logging"
14 "github.com/opencode-ai/opencode/internal/permission"
15 "github.com/opencode-ai/opencode/internal/permission"
15 "github.com/opencode-ai/opencode/internal/pubsub"
16 "github.com/opencode-ai/opencode/internal/pubsub"
17+ "github.com/opencode-ai/opencode/internal/session"
16 "github.com/opencode-ai/opencode/internal/tui/components/chat"
18 "github.com/opencode-ai/opencode/internal/tui/components/chat"
17 "github.com/opencode-ai/opencode/internal/tui/components/core"
19 "github.com/opencode-ai/opencode/internal/tui/components/core"
18 "github.com/opencode-ai/opencode/internal/tui/components/dialog"
20 "github.com/opencode-ai/opencode/internal/tui/components/dialog"
19 "github.com/opencode-ai/opencode/internal/tui/layout"
21 "github.com/opencode-ai/opencode/internal/tui/layout"
20 "github.com/opencode-ai/opencode/internal/tui/page"
22 "github.com/opencode-ai/opencode/internal/tui/page"
23+ "github.com/opencode-ai/opencode/internal/tui/theme"
21 "github.com/opencode-ai/opencode/internal/tui/util"
24 "github.com/opencode-ai/opencode/internal/tui/util"
22)
25)
23
26
@@ -32,6 +35,8 @@ type keyMap struct {
32 SwitchTheme key.Binding
35 SwitchTheme key.Binding
33}
36}
34
37
38+type startCompactSessionMsg struct{}
39+
35const (
40const (
36 quitKey = "q"
41 quitKey = "q"
37)
42)
@@ -91,13 +96,14 @@ var logsKeyReturnKey = key.NewBinding(
91)
96)
92
97
93type appModel struct {
98type appModel struct {
94− width, height int
99+ width, height int
95− currentPage page.PageID
100+ currentPage page.PageID
96− previousPage page.PageID
101+ previousPage page.PageID
97− pages map[page.PageID]tea.Model
102+ pages map[page.PageID]tea.Model
98− loadedPages map[page.PageID]bool
103+ loadedPages map[page.PageID]bool
99− status core.StatusCmp
104+ status core.StatusCmp
100− app *app.App
105+ app *app.App
106+ selectedSession session.Session
101
107
102 showPermissions bool
108 showPermissions bool
103 permissions dialog.PermissionDialogCmp
109 permissions dialog.PermissionDialogCmp
@@ -126,9 +132,12 @@ type appModel struct {
126
132
127 showThemeDialog bool
133 showThemeDialog bool
128 themeDialog dialog.ThemeDialog
134 themeDialog dialog.ThemeDialog
129−
135+
130 showArgumentsDialog bool
136 showArgumentsDialog bool
131 argumentsDialog dialog.ArgumentsDialogCmp
137 argumentsDialog dialog.ArgumentsDialogCmp
138+
139+ isCompacting bool
140+ compactingMessage string
132}
141}
133
142
134func (a appModel) Init() tea.Cmd {
143func (a appModel) Init() tea.Cmd {
@@ -151,6 +160,7 @@ func (a appModel) Init() tea.Cmd {
151 cmd = a.initDialog.Init()
160 cmd = a.initDialog.Init()
152 cmds = append(cmds, cmd)
161 cmds = append(cmds, cmd)
153 cmd = a.filepicker.Init()
162 cmd = a.filepicker.Init()
163+ cmds = append(cmds, cmd)
154 cmd = a.themeDialog.Init()
164 cmd = a.themeDialog.Init()
155 cmds = append(cmds, cmd)
165 cmds = append(cmds, cmd)
156
166
@@ -203,7 +213,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
203 cmds = append(cmds, filepickerCmd)
213 cmds = append(cmds, filepickerCmd)
204
214
205 a.initDialog.SetSize(msg.Width, msg.Height)
215 a.initDialog.SetSize(msg.Width, msg.Height)
206−
216+
207 if a.showArgumentsDialog {
217 if a.showArgumentsDialog {
208 a.argumentsDialog.SetSize(msg.Width, msg.Height)
218 a.argumentsDialog.SetSize(msg.Width, msg.Height)
209 args, argsCmd := a.argumentsDialog.Update(msg)
219 args, argsCmd := a.argumentsDialog.Update(msg)
@@ -293,6 +303,70 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
293 a.showCommandDialog = false
303 a.showCommandDialog = false
294 return a, nil
304 return a, nil
295
305
306+ case startCompactSessionMsg:
307+ // Start compacting the current session
308+ a.isCompacting = true
309+ a.compactingMessage = "Starting summarization..."
310+
311+ if a.selectedSession.ID == "" {
312+ a.isCompacting = false
313+ return a, util.ReportWarn("No active session to summarize")
314+ }
315+
316+ // Start the summarization process
317+ return a, func() tea.Msg {
318+ ctx := context.Background()
319+ a.app.CoderAgent.Summarize(ctx, a.selectedSession.ID)
320+ return nil
321+ }
322+
323+ case pubsub.Event[agent.AgentEvent]:
324+ payload := msg.Payload
325+ if payload.Error != nil {
326+ a.isCompacting = false
327+ return a, util.ReportError(payload.Error)
328+ }
329+
330+ a.compactingMessage = payload.Progress
331+
332+ if payload.Done && payload.Type == agent.AgentEventTypeSummarize {
333+ a.isCompacting = false
334+
335+ if payload.SessionID != "" {
336+ // Switch to the new session
337+ return a, func() tea.Msg {
338+ sessions, err := a.app.Sessions.List(context.Background())
339+ if err != nil {
340+ return util.InfoMsg{
341+ Type: util.InfoTypeError,
342+ Msg: "Failed to list sessions: " + err.Error(),
343+ }
344+ }
345+
346+ for _, s := range sessions {
347+ if s.ID == payload.SessionID {
348+ return dialog.SessionSelectedMsg{Session: s}
349+ }
350+ }
351+
352+ return util.InfoMsg{
353+ Type: util.InfoTypeError,
354+ Msg: "Failed to find new session",
355+ }
356+ }
357+ }
358+ return a, util.ReportInfo("Session summarization complete")
359+ } else if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSession.ID != "" {
360+ model := a.app.CoderAgent.Model()
361+ contextWindow := model.ContextWindow
362+ tokens := a.selectedSession.CompletionTokens + a.selectedSession.PromptTokens
363+ if (tokens >= int64(float64(contextWindow)*0.95)) && config.Get().AutoCompact {
364+ return a, util.CmdHandler(startCompactSessionMsg{})
365+ }
366+ }
367+ // Continue listening for events
368+ return a, nil
369+
296 case dialog.CloseThemeDialogMsg:
370 case dialog.CloseThemeDialogMsg:
297 a.showThemeDialog = false
371 a.showThemeDialog = false
298 return a, nil
372 return a, nil
@@ -342,7 +416,13 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
342 return a, nil
416 return a, nil
343
417
344 case chat.SessionSelectedMsg:
418 case chat.SessionSelectedMsg:
419+ a.selectedSession = msg
345 a.sessionDialog.SetSelectedSession(msg.ID)
420 a.sessionDialog.SetSelectedSession(msg.ID)
421+
422+ case pubsub.Event[session.Session]:
423+ if msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == a.selectedSession.ID {
424+ a.selectedSession = msg.Payload
425+ }
346 case dialog.SessionSelectedMsg:
426 case dialog.SessionSelectedMsg:
347 a.showSessionDialog = false
427 a.showSessionDialog = false
348 if a.currentPage == page.ChatPage {
428 if a.currentPage == page.ChatPage {
@@ -357,22 +437,22 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
357 return a, msg.Command.Handler(msg.Command)
437 return a, msg.Command.Handler(msg.Command)
358 }
438 }
359 return a, util.ReportInfo("Command selected: " + msg.Command.Title)
439 return a, util.ReportInfo("Command selected: " + msg.Command.Title)
360−
440+
361 case dialog.ShowArgumentsDialogMsg:
441 case dialog.ShowArgumentsDialogMsg:
362 // Show arguments dialog
442 // Show arguments dialog
363 a.argumentsDialog = dialog.NewArgumentsDialogCmp(msg.CommandID, msg.Content)
443 a.argumentsDialog = dialog.NewArgumentsDialogCmp(msg.CommandID, msg.Content)
364 a.showArgumentsDialog = true
444 a.showArgumentsDialog = true
365 return a, a.argumentsDialog.Init()
445 return a, a.argumentsDialog.Init()
366−
446+
367 case dialog.CloseArgumentsDialogMsg:
447 case dialog.CloseArgumentsDialogMsg:
368 // Close arguments dialog
448 // Close arguments dialog
369 a.showArgumentsDialog = false
449 a.showArgumentsDialog = false
370−
450+
371 // If submitted, replace $ARGUMENTS and run the command
451 // If submitted, replace $ARGUMENTS and run the command
372 if msg.Submit {
452 if msg.Submit {
373 // Replace $ARGUMENTS with the provided arguments
453 // Replace $ARGUMENTS with the provided arguments
374 content := strings.ReplaceAll(msg.Content, "$ARGUMENTS", msg.Arguments)
454 content := strings.ReplaceAll(msg.Content, "$ARGUMENTS", msg.Arguments)
375−
455+
376 // Execute the command with arguments
456 // Execute the command with arguments
377 return a, util.CmdHandler(dialog.CommandRunCustomMsg{
457 return a, util.CmdHandler(dialog.CommandRunCustomMsg{
378 Content: content,
458 Content: content,
@@ -387,7 +467,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
387 a.argumentsDialog = args.(dialog.ArgumentsDialogCmp)
467 a.argumentsDialog = args.(dialog.ArgumentsDialogCmp)
388 return a, cmd
468 return a, cmd
389 }
469 }
390−
470+
391 switch {
471 switch {
392
472
393 case key.Matches(msg, keys.Quit):
473 case key.Matches(msg, keys.Quit):
@@ -606,6 +686,15 @@ func (a *appModel) RegisterCommand(cmd dialog.Command) {
606 a.commands = append(a.commands, cmd)
686 a.commands = append(a.commands, cmd)
607}
687}
608
688
689+func (a *appModel) findCommand(id string) (dialog.Command, bool) {
690+ for _, cmd := range a.commands {
691+ if cmd.ID == id {
692+ return cmd, true
693+ }
694+ }
695+ return dialog.Command{}, false
696+}
697+
609func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
698func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
610 if a.app.CoderAgent.IsBusy() {
699 if a.app.CoderAgent.IsBusy() {
611 // For now we don't move to any page if the agent is busy
700 // For now we don't move to any page if the agent is busy
@@ -668,10 +757,29 @@ func (a appModel) View() string {
668
757
669 }
758 }
670
759
671− if !a.app.CoderAgent.IsBusy() {
760+ // Show compacting status overlay
672− a.status.SetHelpWidgetMsg("ctrl+? help")
761+ if a.isCompacting {
673− } else {
762+ t := theme.CurrentTheme()
674− a.status.SetHelpWidgetMsg("? help")
763+ style := lipgloss.NewStyle().
764+ Border(lipgloss.RoundedBorder()).
765+ BorderForeground(t.BorderFocused()).
766+ BorderBackground(t.Background()).
767+ Padding(1, 2).
768+ Background(t.Background()).
769+ Foreground(t.Text())
770+
771+ overlay := style.Render("Summarizing\n" + a.compactingMessage)
772+ row := lipgloss.Height(appView) / 2
773+ row -= lipgloss.Height(overlay) / 2
774+ col := lipgloss.Width(appView) / 2
775+ col -= lipgloss.Width(overlay) / 2
776+ appView = layout.PlaceOverlay(
777+ col,
778+ row,
779+ overlay,
780+ appView,
781+ true,
782+ )
675 }
783 }
676
784
677 if a.showHelp {
785 if a.showHelp {
@@ -789,7 +897,7 @@ func (a appModel) View() string {
789 true,
897 true,
790 )
898 )
791 }
899 }
792−
900+
793 if a.showArgumentsDialog {
901 if a.showArgumentsDialog {
794 overlay := a.argumentsDialog.View()
902 overlay := a.argumentsDialog.View()
795 row := lipgloss.Height(appView) / 2
903 row := lipgloss.Height(appView) / 2
@@ -850,7 +958,17 @@ If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (
850 )
958 )
851 },
959 },
852 })
960 })
853−
961+
962+ model.RegisterCommand(dialog.Command{
963+ ID: "compact",
964+ Title: "Compact Session",
965+ Description: "Summarize the current session and create a new one with the summary",
966+ Handler: func(cmd dialog.Command) tea.Cmd {
967+ return func() tea.Msg {
968+ return startCompactSessionMsg{}
969+ }
970+ },
971+ })
854 // Load custom commands
972 // Load custom commands
855 customCommands, err := dialog.LoadCustomCommands()
973 customCommands, err := dialog.LoadCustomCommands()
856 if err != nil {
974 if err != nil {
@@ -860,6 +978,6 @@ If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (
860 model.RegisterCommand(cmd)
978 model.RegisterCommand(cmd)
861 }
979 }
862 }
980 }
863−
981+
864 return model
982 return model
865}
983}
scripts/check_hidden_chars.shnew file+41
@@ -0,0 +1,41 @@
1+#!/bin/bash
2+
3+# Script to check for hidden/invisible characters in Go files
4+# This helps detect potential prompt injection attempts
5+
6+echo "Checking Go files for hidden characters..."
7+
8+# Find all Go files in the repository
9+go_files=$(find . -name "*.go" -type f)
10+
11+# Counter for files with hidden characters
12+files_with_hidden=0
13+
14+for file in $go_files; do
15+ # Check for specific Unicode hidden characters that could be used for prompt injection
16+ # This excludes normal whitespace like tabs and newlines
17+ # Looking for:
18+ # - Zero-width spaces (U+200B)
19+ # - Zero-width non-joiners (U+200C)
20+ # - Zero-width joiners (U+200D)
21+ # - Left-to-right/right-to-left marks (U+200E, U+200F)
22+ # - Bidirectional overrides (U+202A-U+202E)
23+ # - Byte order mark (U+FEFF)
24+ 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
25+ echo "Hidden characters found in: $file"
26+
27+ # Show the file with potential issues
28+ echo " Hexdump showing suspicious characters:"
29+ 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
30+
31+ files_with_hidden=$((files_with_hidden + 1))
32+ fi
33+done
34+
35+if [ $files_with_hidden -eq 0 ]; then
36+ echo "No hidden characters found in any Go files."
37+else
38+ echo "Found hidden characters in $files_with_hidden Go file(s)."
39+fi
40+
41+exit $files_with_hidden # Exit with number of affected files as status code