on main help sign in
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

commit90084ce43d7a44c4dea98705694f34d01dbe192a authorKujtim Hoxha <kujtimii.h@gmail.com> dateFri 9 May 2025, 19:30 (1 year ago) parent9345830
12 files changed+537−98
README.md+31−3 cmd/root.go+1 internal/config/config.go+14−3 internal/llm/agent/agent-tool.go+3−5 internal/llm/agent/agent.go+216−22 internal/llm/prompt/prompt.go+2 internal/llm/prompt/summarizer.go+16 internal/tui/components/core/status.go+33−28 internal/tui/components/dialog/filepicker.go+1−4 internal/tui/components/dialog/permission.go+40−12 internal/tui/tui.go+139−21 scripts/check_hidden_chars.sh+41
README.md+31−3
@@ -62,12 +62,29 @@ OpenCode looks for configuration in the following locations:
6262- `$XDG_CONFIG_HOME/opencode/.opencode.json`
6363- `./.opencode.json` (local directory)
6464
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+
6582### Environment Variables
6683
6784You can configure OpenCode using environment variables:
6885
6986| Environment Variable | Purpose |
70−|----------------------------|--------------------------------------------------------|
87+| -------------------------- | ------------------------------------------------------ |
7188| `ANTHROPIC_API_KEY` | For Claude models |
7289| `OPENAI_API_KEY` | For OpenAI models |
7390| `GEMINI_API_KEY` | For Google Gemini models |
@@ -79,7 +96,6 @@ You can configure OpenCode using environment variables:
7996| `AZURE_OPENAI_API_KEY` | For Azure OpenAI models (optional when using Entra ID) |
8097| `AZURE_OPENAI_API_VERSION` | For Azure OpenAI models |
8198
82−
8399### Configuration File Structure
84100
85101```json
@@ -134,7 +150,8 @@ You can configure OpenCode using environment variables:
134150 }
135151 },
136152 "debug": false,
137− "debugLSP": false
153+ "debugLSP": false,
154+ "autoCompact": true
138155}
139156```
140157
@@ -327,9 +344,11 @@ OpenCode supports custom commands that can be created by users to quickly send p
327344Custom commands are predefined prompts stored as Markdown files in one of three locations:
328345
3293461. **User Commands** (prefixed with `user:`):
347+
330348 ```
331349 $XDG_CONFIG_HOME/opencode/commands/
332350 ```
351+
333352 (typically `~/.config/opencode/commands/` on Linux/macOS)
334353
335354 or
@@ -382,6 +401,15 @@ This creates a command with ID `user:git:commit`.
382401
383402The content of the command file will be sent as a message to the AI assistant.
384403
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+
385413## MCP (Model Context Protocol)
386414
387415OpenCode 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,
218218 setupSubscriber(ctx, &wg, "sessions", app.Sessions.Subscribe, ch)
219219 setupSubscriber(ctx, &wg, "messages", app.Messages.Subscribe, ch)
220220 setupSubscriber(ctx, &wg, "permissions", app.Permissions.Subscribe, ch)
221+ setupSubscriber(ctx, &wg, "coderAgent", app.CoderAgent.Subscribe, ch)
221222
222223 cleanupFunc := func() {
223224 logging.Info("Cancelling all subscriptions")
internal/config/config.go+14−3
@@ -36,9 +36,10 @@ type MCPServer struct {
3636type AgentName string
3737
3838const (
39− AgentCoder AgentName = "coder"
40− AgentTask AgentName = "task"
41− AgentTitle AgentName = "title"
39+ AgentCoder AgentName = "coder"
40+ AgentSummarizer AgentName = "summarizer"
41+ AgentTask AgentName = "task"
42+ AgentTitle AgentName = "title"
4243)
4344
4445// Agent defines configuration for different LLM models and their token limits.
@@ -84,6 +85,7 @@ type Config struct {
8485 DebugLSP bool `json:"debugLSP,omitempty"`
8586 ContextPaths []string `json:"contextPaths,omitempty"`
8687 TUI TUIConfig `json:"tui"`
88+ AutoCompact bool `json:"autoCompact,omitempty"`
8789}
8890
8991// Application constants
@@ -213,6 +215,7 @@ func setDefaults(debug bool) {
213215 viper.SetDefault("data.directory", defaultDataDirectory)
214216 viper.SetDefault("contextPaths", defaultContextPaths)
215217 viper.SetDefault("tui.theme", "opencode")
218+ viper.SetDefault("autoCompact", true)
216219
217220 if debug {
218221 viper.SetDefault("debug", true)
@@ -262,6 +265,7 @@ func setProviderDefaults() {
262265 // Anthropic configuration
263266 if key := viper.GetString("providers.anthropic.apiKey"); strings.TrimSpace(key) != "" {
264267 viper.SetDefault("agents.coder.model", models.Claude37Sonnet)
268+ viper.SetDefault("agents.summarizer.model", models.Claude37Sonnet)
265269 viper.SetDefault("agents.task.model", models.Claude37Sonnet)
266270 viper.SetDefault("agents.title.model", models.Claude37Sonnet)
267271 return
@@ -270,6 +274,7 @@ func setProviderDefaults() {
270274 // OpenAI configuration
271275 if key := viper.GetString("providers.openai.apiKey"); strings.TrimSpace(key) != "" {
272276 viper.SetDefault("agents.coder.model", models.GPT41)
277+ viper.SetDefault("agents.summarizer.model", models.GPT41)
273278 viper.SetDefault("agents.task.model", models.GPT41Mini)
274279 viper.SetDefault("agents.title.model", models.GPT41Mini)
275280 return
@@ -278,6 +283,7 @@ func setProviderDefaults() {
278283 // Google Gemini configuration
279284 if key := viper.GetString("providers.gemini.apiKey"); strings.TrimSpace(key) != "" {
280285 viper.SetDefault("agents.coder.model", models.Gemini25)
286+ viper.SetDefault("agents.summarizer.model", models.Gemini25)
281287 viper.SetDefault("agents.task.model", models.Gemini25Flash)
282288 viper.SetDefault("agents.title.model", models.Gemini25Flash)
283289 return
@@ -286,6 +292,7 @@ func setProviderDefaults() {
286292 // Groq configuration
287293 if key := viper.GetString("providers.groq.apiKey"); strings.TrimSpace(key) != "" {
288294 viper.SetDefault("agents.coder.model", models.QWENQwq)
295+ viper.SetDefault("agents.summarizer.model", models.QWENQwq)
289296 viper.SetDefault("agents.task.model", models.QWENQwq)
290297 viper.SetDefault("agents.title.model", models.QWENQwq)
291298 return
@@ -294,6 +301,7 @@ func setProviderDefaults() {
294301 // OpenRouter configuration
295302 if key := viper.GetString("providers.openrouter.apiKey"); strings.TrimSpace(key) != "" {
296303 viper.SetDefault("agents.coder.model", models.OpenRouterClaude37Sonnet)
304+ viper.SetDefault("agents.summarizer.model", models.OpenRouterClaude37Sonnet)
297305 viper.SetDefault("agents.task.model", models.OpenRouterClaude37Sonnet)
298306 viper.SetDefault("agents.title.model", models.OpenRouterClaude35Haiku)
299307 return
@@ -302,6 +310,7 @@ func setProviderDefaults() {
302310 // XAI configuration
303311 if key := viper.GetString("providers.xai.apiKey"); strings.TrimSpace(key) != "" {
304312 viper.SetDefault("agents.coder.model", models.XAIGrok3Beta)
313+ viper.SetDefault("agents.summarizer.model", models.XAIGrok3Beta)
305314 viper.SetDefault("agents.task.model", models.XAIGrok3Beta)
306315 viper.SetDefault("agents.title.model", models.XAiGrok3MiniFastBeta)
307316 return
@@ -310,6 +319,7 @@ func setProviderDefaults() {
310319 // AWS Bedrock configuration
311320 if hasAWSCredentials() {
312321 viper.SetDefault("agents.coder.model", models.BedrockClaude37Sonnet)
322+ viper.SetDefault("agents.summarizer.model", models.BedrockClaude37Sonnet)
313323 viper.SetDefault("agents.task.model", models.BedrockClaude37Sonnet)
314324 viper.SetDefault("agents.title.model", models.BedrockClaude37Sonnet)
315325 return
@@ -318,6 +328,7 @@ func setProviderDefaults() {
318328 // Azure OpenAI configuration
319329 if os.Getenv("AZURE_OPENAI_ENDPOINT") != "" {
320330 viper.SetDefault("agents.coder.model", models.AzureGPT41)
331+ viper.SetDefault("agents.summarizer.model", models.AzureGPT41)
321332 viper.SetDefault("agents.task.model", models.AzureGPT41Mini)
322333 viper.SetDefault("agents.title.model", models.AzureGPT41Mini)
323334 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
6969 return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", err)
7070 }
7171 result := <-done
72− if result.Err() != nil {
73− return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", result.Err())
72+ if result.Error != nil {
73+ return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", result.Error)
7474 }
7575
76− response := result.Response()
76+ response := result.Message
7777 if response.Role != message.Assistant {
7878 return tools.NewTextErrorResponse("no response"), nil
7979 }
@@ -88,8 +88,6 @@ func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolRes
8888 }
8989
9090 parentSession.Cost += updatedSession.Cost
91− parentSession.PromptTokens += updatedSession.PromptTokens
92− parentSession.CompletionTokens += updatedSession.CompletionTokens
9391
9492 _, err = b.sessions.Save(ctx, parentSession)
9593 if err != nil {
internal/llm/agent/agent.go+216−22
@@ -15,6 +15,7 @@ import (
1515 "github.com/opencode-ai/opencode/internal/logging"
1616 "github.com/opencode-ai/opencode/internal/message"
1717 "github.com/opencode-ai/opencode/internal/permission"
18+ "github.com/opencode-ai/opencode/internal/pubsub"
1819 "github.com/opencode-ai/opencode/internal/session"
1920)
2021
@@ -24,35 +25,46 @@ var (
2425 ErrSessionBusy = errors.New("session is currently processing another request")
2526)
2627
27−type AgentEvent struct {
28− message message.Message
29− err error
30−}
28+type AgentEventType string
3129
32−func (e *AgentEvent) Err() error {
33− return e.err
34−}
30+const (
31+ AgentEventTypeError AgentEventType = "error"
32+ AgentEventTypeResponse AgentEventType = "response"
33+ AgentEventTypeSummarize AgentEventType = "summarize"
34+)
3535
36−func (e *AgentEvent) Response() message.Message {
37− return e.message
36+type AgentEvent struct {
37+ Type AgentEventType
38+ Message message.Message
39+ Error error
40+
41+ // When summarizing
42+ SessionID string
43+ Progress string
44+ Done bool
3845}
3946
4047type Service interface {
48+ pubsub.Suscriber[AgentEvent]
49+ Model() models.Model
4150 Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
4251 Cancel(sessionID string)
4352 IsSessionBusy(sessionID string) bool
4453 IsBusy() bool
4554 Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error)
55+ Summarize(ctx context.Context, sessionID string) error
4656}
4757
4858type agent struct {
59+ *pubsub.Broker[AgentEvent]
4960 sessions session.Service
5061 messages message.Service
5162
5263 tools []tools.BaseTool
5364 provider provider.Provider
5465
55− titleProvider provider.Provider
66+ titleProvider provider.Provider
67+ summarizeProvider provider.Provider
5668
5769 activeRequests sync.Map
5870}
@@ -75,26 +87,48 @@ func NewAgent(
7587 return nil, err
7688 }
7789 }
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+ }
7897
7998 agent := &agent{
80− provider: agentProvider,
81− messages: messages,
82− sessions: sessions,
83− tools: agentTools,
84− titleProvider: titleProvider,
85− activeRequests: sync.Map{},
99+ Broker: pubsub.NewBroker[AgentEvent](),
100+ provider: agentProvider,
101+ messages: messages,
102+ sessions: sessions,
103+ tools: agentTools,
104+ titleProvider: titleProvider,
105+ summarizeProvider: summarizeProvider,
106+ activeRequests: sync.Map{},
86107 }
87108
88109 return agent, nil
89110}
90111
112+func (a *agent) Model() models.Model {
113+ return a.provider.Model()
114+}
115+
91116func (a *agent) Cancel(sessionID string) {
117+ // Cancel regular requests
92118 if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID); exists {
93119 if cancel, ok := cancelFunc.(context.CancelFunc); ok {
94120 logging.InfoPersist(fmt.Sprintf("Request cancellation initiated for session: %s", sessionID))
95121 cancel()
96122 }
97123 }
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+ }
98132}
99133
100134func (a *agent) IsBusy() bool {
@@ -154,7 +188,8 @@ func (a *agent) generateTitle(ctx context.Context, sessionID string, content str
154188
155189func (a *agent) err(err error) AgentEvent {
156190 return AgentEvent{
157− err: err,
191+ Type: AgentEventTypeError,
192+ Error: err,
158193 }
159194}
160195
@@ -180,12 +215,13 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
180215 attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
181216 }
182217 result := a.processGeneration(genCtx, sessionID, content, attachmentParts)
183− if result.Err() != nil && !errors.Is(result.Err(), ErrRequestCancelled) && !errors.Is(result.Err(), context.Canceled) {
184− logging.ErrorPersist(result.Err().Error())
218+ if result.Error != nil && !errors.Is(result.Error, ErrRequestCancelled) && !errors.Is(result.Error, context.Canceled) {
219+ logging.ErrorPersist(result.Error.Error())
185220 }
186221 logging.Debug("Request completed", "sessionID", sessionID)
187222 a.activeRequests.Delete(sessionID)
188223 cancel()
224+ a.Publish(pubsub.CreatedEvent, result)
189225 events <- result
190226 close(events)
191227 }()
@@ -241,7 +277,9 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
241277 continue
242278 }
243279 return AgentEvent{
244− message: agentMessage,
280+ Type: AgentEventTypeResponse,
281+ Message: agentMessage,
282+ Done: true,
245283 }
246284 }
247285}
@@ -432,8 +470,8 @@ func (a *agent) TrackUsage(ctx context.Context, sessionID string, model models.M
432470 model.CostPer1MOut/1e6*float64(usage.OutputTokens)
433471
434472 sess.Cost += cost
435− sess.CompletionTokens += usage.OutputTokens
436− sess.PromptTokens += usage.InputTokens
473+ sess.CompletionTokens = usage.OutputTokens + usage.CacheReadTokens
474+ sess.PromptTokens = usage.InputTokens + usage.CacheCreationTokens
437475
438476 _, err = a.sessions.Save(ctx, sess)
439477 if err != nil {
@@ -461,6 +499,162 @@ func (a *agent) Update(agentName config.AgentName, modelID models.ModelID) (mode
461499 return a.provider.Model(), nil
462500}
463501
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+
464658func createAgentProvider(agentName config.AgentName) (provider.Provider, error) {
465659 cfg := config.Get()
466660 agentConfig, ok := cfg.Agents[agentName]
internal/llm/prompt/prompt.go+2
@@ -21,6 +21,8 @@ func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) s
2121 basePrompt = TitlePrompt(provider)
2222 case config.AgentTask:
2323 basePrompt = TaskPrompt(provider)
24+ case config.AgentSummarizer:
25+ basePrompt = SummarizerPrompt(provider)
2426 default:
2527 basePrompt = "You are a helpful assistant"
2628 }
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 (
2121
2222type StatusCmp interface {
2323 tea.Model
24− SetHelpWidgetMsg(string)
2524}
2625
2726type statusCmp struct {
@@ -74,11 +73,9 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
7473var helpWidget = ""
7574
7675// getHelpWidget returns the help widget with current theme colors
77−func getHelpWidget(helpText string) string {
76+func getHelpWidget() string {
7877 t := theme.CurrentTheme()
79− if helpText == "" {
80− helpText = "ctrl+? help"
81− }
78+ helpText := "ctrl+? help"
8279
8380 return styles.Padded().
8481 Background(t.TextMuted()).
@@ -87,7 +84,7 @@ func getHelpWidget(helpText string) string {
8784 Render(helpText)
8885}
8986
90−func formatTokensAndCost(tokens int64, cost float64) string {
87+func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
9188 // Format tokens in human-readable format (e.g., 110K, 1.2M)
9289 var formattedTokens string
9390 switch {
@@ -110,32 +107,48 @@ func formatTokensAndCost(tokens int64, cost float64) string {
110107 // Format cost with $ symbol and 2 decimal places
111108 formattedCost := fmt.Sprintf("$%.2f", cost)
112109
113− return fmt.Sprintf("Tokens: %s, Cost: %s", formattedTokens, formattedCost)
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+
116+ return fmt.Sprintf("Context: %s, Cost: %s", formattedTokens, formattedCost)
114117}
115118
116119func (m statusCmp) View() string {
117120 t := theme.CurrentTheme()
121+ modelID := config.Get().Agents[config.AgentCoder].Model
122+ model := models.SupportedModels[modelID]
118123
119124 // Initialize the help widget
120− status := getHelpWidget("")
125+ status := getHelpWidget()
121126
127+ tokenInfoWidth := 0
122128 if m.session.ID != "" {
123− tokens := formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
129+ totalTokens := m.session.PromptTokens + m.session.CompletionTokens
130+ tokens := formatTokensAndCost(totalTokens, model.ContextWindow, m.session.Cost)
124131 tokensStyle := styles.Padded().
125132 Background(t.Text()).
126− Foreground(t.BackgroundSecondary()).
127− Render(tokens)
128− status += tokensStyle
133+ Foreground(t.BackgroundSecondary())
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
139+ status += tokensStyle.Render(tokens)
129140 }
130141
131142 diagnostics := styles.Padded().
132143 Background(t.BackgroundDarker()).
133144 Render(m.projectDiagnostics())
134145
146+ availableWidht := max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokenInfoWidth)
147+
135148 if m.info.Msg != "" {
136149 infoStyle := styles.Padded().
137150 Foreground(t.Background()).
138− Width(m.availableFooterMsgWidth(diagnostics))
151+ Width(availableWidht)
139152
140153 switch m.info.Type {
141154 case util.InfoTypeInfo:
@@ -146,18 +159,18 @@ func (m statusCmp) View() string {
146159 infoStyle = infoStyle.Background(t.Error())
147160 }
148161
162+ infoWidth := availableWidht - 10
149163 // Truncate message if it's longer than available width
150164 msg := m.info.Msg
151− availWidth := m.availableFooterMsgWidth(diagnostics) - 10
152− if len(msg) > availWidth && availWidth > 0 {
153− msg = msg[:availWidth] + "..."
165+ if len(msg) > infoWidth && infoWidth > 0 {
166+ msg = msg[:infoWidth] + "..."
154167 }
155168 status += infoStyle.Render(msg)
156169 } else {
157170 status += styles.Padded().
158171 Foreground(t.Text()).
159172 Background(t.BackgroundSecondary()).
160− Width(m.availableFooterMsgWidth(diagnostics)).
173+ Width(availableWidht).
161174 Render("")
162175 }
163176
@@ -245,12 +258,10 @@ func (m *statusCmp) projectDiagnostics() string {
245258 return strings.Join(diagnostics, " ")
246259}
247260
248−func (m statusCmp) availableFooterMsgWidth(diagnostics string) int {
249− tokens := ""
261+func (m statusCmp) availableFooterMsgWidth(diagnostics, tokenInfo string) int {
250262 tokensWidth := 0
251263 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
254265 }
255266 return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokensWidth)
256267}
@@ -272,14 +283,8 @@ func (m statusCmp) model() string {
272283 Render(model.Name)
273284}
274285
275−func (m statusCmp) SetHelpWidgetMsg(s string) {
276− // Update the help widget text using the getHelpWidget function
277− helpWidget = getHelpWidget(s)
278−}
279−
280286func NewStatusCmp(lspClients map[string]*lsp.Client) StatusCmp {
281− // Initialize the help widget with default text
282− helpWidget = getHelpWidget("")
287+ helpWidget = getHelpWidget()
283288
284289 return &statusCmp{
285290 messageTTL: 10 * time.Second,
internal/tui/components/dialog/filepicker.go+1−4
@@ -302,11 +302,8 @@ func (f *filepickerCmp) View() string {
302302 }
303303 if file.IsDir() {
304304 filename = filename + "/"
305− } else if isExtSupported(file.Name()) {
306− filename = filename
307− } else {
308− filename = filename
309305 }
306+ // No need to reassign filename if it's not changing
310307
311308 files = append(files, itemStyle.Padding(0, 1).Render(filename))
312309 }
internal/tui/components/dialog/permission.go+40−12
@@ -2,6 +2,8 @@ package dialog
22
33import (
44 "fmt"
5+ "strings"
6+
57 "github.com/charmbracelet/bubbles/key"
68 "github.com/charmbracelet/bubbles/viewport"
79 tea "github.com/charmbracelet/bubbletea"
@@ -13,7 +15,6 @@ import (
1315 "github.com/opencode-ai/opencode/internal/tui/styles"
1416 "github.com/opencode-ai/opencode/internal/tui/theme"
1517 "github.com/opencode-ai/opencode/internal/tui/util"
16− "strings"
1718)
1819
1920type PermissionAction string
@@ -150,7 +151,7 @@ func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
150151func (p *permissionDialogCmp) renderButtons() string {
151152 t := theme.CurrentTheme()
152153 baseStyle := styles.BaseStyle()
153−
154+
154155 allowStyle := baseStyle
155156 allowSessionStyle := baseStyle
156157 denyStyle := baseStyle
@@ -196,7 +197,7 @@ func (p *permissionDialogCmp) renderButtons() string {
196197func (p *permissionDialogCmp) renderHeader() string {
197198 t := theme.CurrentTheme()
198199 baseStyle := styles.BaseStyle()
199−
200+
200201 toolKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Tool")
201202 toolValue := baseStyle.
202203 Foreground(t.Text()).
@@ -229,9 +230,36 @@ func (p *permissionDialogCmp) renderHeader() string {
229230 case tools.BashToolName:
230231 headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Command"))
231232 case tools.EditToolName:
232− headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Diff"))
233+ params := p.permission.Params.(tools.EditPermissionsParams)
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+
233248 case tools.WriteToolName:
234− headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Diff"))
249+ params := p.permission.Params.(tools.WritePermissionsParams)
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+ )
235263 case tools.FetchToolName:
236264 headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("URL"))
237265 }
@@ -242,13 +270,13 @@ func (p *permissionDialogCmp) renderHeader() string {
242270func (p *permissionDialogCmp) renderBashContent() string {
243271 t := theme.CurrentTheme()
244272 baseStyle := styles.BaseStyle()
245−
273+
246274 if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
247275 content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
248276
249277 // Use the cache for markdown rendering
250278 renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
251− r := styles.GetMarkdownRenderer(p.width-10)
279+ r := styles.GetMarkdownRenderer(p.width - 10)
252280 s, err := r.Render(content)
253281 return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
254282 })
@@ -302,13 +330,13 @@ func (p *permissionDialogCmp) renderWriteContent() string {
302330func (p *permissionDialogCmp) renderFetchContent() string {
303331 t := theme.CurrentTheme()
304332 baseStyle := styles.BaseStyle()
305−
333+
306334 if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
307335 content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
308336
309337 // Use the cache for markdown rendering
310338 renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
311− r := styles.GetMarkdownRenderer(p.width-10)
339+ r := styles.GetMarkdownRenderer(p.width - 10)
312340 s, err := r.Render(content)
313341 return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
314342 })
@@ -325,12 +353,12 @@ func (p *permissionDialogCmp) renderFetchContent() string {
325353func (p *permissionDialogCmp) renderDefaultContent() string {
326354 t := theme.CurrentTheme()
327355 baseStyle := styles.BaseStyle()
328−
356+
329357 content := p.permission.Description
330358
331359 // Use the cache for markdown rendering
332360 renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
333− r := styles.GetMarkdownRenderer(p.width-10)
361+ r := styles.GetMarkdownRenderer(p.width - 10)
334362 s, err := r.Render(content)
335363 return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
336364 })
@@ -358,7 +386,7 @@ func (p *permissionDialogCmp) styleViewport() string {
358386func (p *permissionDialogCmp) render() string {
359387 t := theme.CurrentTheme()
360388 baseStyle := styles.BaseStyle()
361−
389+
362390 title := baseStyle.
363391 Bold(true).
364392 Width(p.width - 4).
internal/tui/tui.go+139−21
@@ -10,14 +10,17 @@ import (
1010 "github.com/charmbracelet/lipgloss"
1111 "github.com/opencode-ai/opencode/internal/app"
1212 "github.com/opencode-ai/opencode/internal/config"
13+ "github.com/opencode-ai/opencode/internal/llm/agent"
1314 "github.com/opencode-ai/opencode/internal/logging"
1415 "github.com/opencode-ai/opencode/internal/permission"
1516 "github.com/opencode-ai/opencode/internal/pubsub"
17+ "github.com/opencode-ai/opencode/internal/session"
1618 "github.com/opencode-ai/opencode/internal/tui/components/chat"
1719 "github.com/opencode-ai/opencode/internal/tui/components/core"
1820 "github.com/opencode-ai/opencode/internal/tui/components/dialog"
1921 "github.com/opencode-ai/opencode/internal/tui/layout"
2022 "github.com/opencode-ai/opencode/internal/tui/page"
23+ "github.com/opencode-ai/opencode/internal/tui/theme"
2124 "github.com/opencode-ai/opencode/internal/tui/util"
2225)
2326
@@ -32,6 +35,8 @@ type keyMap struct {
3235 SwitchTheme key.Binding
3336}
3437
38+type startCompactSessionMsg struct{}
39+
3540const (
3641 quitKey = "q"
3742)
@@ -91,13 +96,14 @@ var logsKeyReturnKey = key.NewBinding(
9196)
9297
9398type appModel struct {
94− width, height int
95− currentPage page.PageID
96− previousPage page.PageID
97− pages map[page.PageID]tea.Model
98− loadedPages map[page.PageID]bool
99− status core.StatusCmp
100− app *app.App
99+ width, height int
100+ currentPage page.PageID
101+ previousPage page.PageID
102+ pages map[page.PageID]tea.Model
103+ loadedPages map[page.PageID]bool
104+ status core.StatusCmp
105+ app *app.App
106+ selectedSession session.Session
101107
102108 showPermissions bool
103109 permissions dialog.PermissionDialogCmp
@@ -126,9 +132,12 @@ type appModel struct {
126132
127133 showThemeDialog bool
128134 themeDialog dialog.ThemeDialog
129−
135+
130136 showArgumentsDialog bool
131137 argumentsDialog dialog.ArgumentsDialogCmp
138+
139+ isCompacting bool
140+ compactingMessage string
132141}
133142
134143func (a appModel) Init() tea.Cmd {
@@ -151,6 +160,7 @@ func (a appModel) Init() tea.Cmd {
151160 cmd = a.initDialog.Init()
152161 cmds = append(cmds, cmd)
153162 cmd = a.filepicker.Init()
163+ cmds = append(cmds, cmd)
154164 cmd = a.themeDialog.Init()
155165 cmds = append(cmds, cmd)
156166
@@ -203,7 +213,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
203213 cmds = append(cmds, filepickerCmd)
204214
205215 a.initDialog.SetSize(msg.Width, msg.Height)
206−
216+
207217 if a.showArgumentsDialog {
208218 a.argumentsDialog.SetSize(msg.Width, msg.Height)
209219 args, argsCmd := a.argumentsDialog.Update(msg)
@@ -293,6 +303,70 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
293303 a.showCommandDialog = false
294304 return a, nil
295305
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+
296370 case dialog.CloseThemeDialogMsg:
297371 a.showThemeDialog = false
298372 return a, nil
@@ -342,7 +416,13 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
342416 return a, nil
343417
344418 case chat.SessionSelectedMsg:
419+ a.selectedSession = msg
345420 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+ }
346426 case dialog.SessionSelectedMsg:
347427 a.showSessionDialog = false
348428 if a.currentPage == page.ChatPage {
@@ -357,22 +437,22 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
357437 return a, msg.Command.Handler(msg.Command)
358438 }
359439 return a, util.ReportInfo("Command selected: " + msg.Command.Title)
360−
440+
361441 case dialog.ShowArgumentsDialogMsg:
362442 // Show arguments dialog
363443 a.argumentsDialog = dialog.NewArgumentsDialogCmp(msg.CommandID, msg.Content)
364444 a.showArgumentsDialog = true
365445 return a, a.argumentsDialog.Init()
366−
446+
367447 case dialog.CloseArgumentsDialogMsg:
368448 // Close arguments dialog
369449 a.showArgumentsDialog = false
370−
450+
371451 // If submitted, replace $ARGUMENTS and run the command
372452 if msg.Submit {
373453 // Replace $ARGUMENTS with the provided arguments
374454 content := strings.ReplaceAll(msg.Content, "$ARGUMENTS", msg.Arguments)
375−
455+
376456 // Execute the command with arguments
377457 return a, util.CmdHandler(dialog.CommandRunCustomMsg{
378458 Content: content,
@@ -387,7 +467,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
387467 a.argumentsDialog = args.(dialog.ArgumentsDialogCmp)
388468 return a, cmd
389469 }
390−
470+
391471 switch {
392472
393473 case key.Matches(msg, keys.Quit):
@@ -606,6 +686,15 @@ func (a *appModel) RegisterCommand(cmd dialog.Command) {
606686 a.commands = append(a.commands, cmd)
607687}
608688
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+
609698func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
610699 if a.app.CoderAgent.IsBusy() {
611700 // For now we don't move to any page if the agent is busy
@@ -668,10 +757,29 @@ func (a appModel) View() string {
668757
669758 }
670759
671− if !a.app.CoderAgent.IsBusy() {
672− a.status.SetHelpWidgetMsg("ctrl+? help")
673− } else {
674− a.status.SetHelpWidgetMsg("? help")
760+ // Show compacting status overlay
761+ if a.isCompacting {
762+ t := theme.CurrentTheme()
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+ )
675783 }
676784
677785 if a.showHelp {
@@ -789,7 +897,7 @@ func (a appModel) View() string {
789897 true,
790898 )
791899 }
792−
900+
793901 if a.showArgumentsDialog {
794902 overlay := a.argumentsDialog.View()
795903 row := lipgloss.Height(appView) / 2
@@ -850,7 +958,17 @@ If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (
850958 )
851959 },
852960 })
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+ })
854972 // Load custom commands
855973 customCommands, err := dialog.LoadCustomCommands()
856974 if err != nil {
@@ -860,6 +978,6 @@ If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (
860978 model.RegisterCommand(cmd)
861979 }
862980 }
863−
981+
864982 return model
865983}
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