Merge pull request #22 from adamdottv/adam/retries

Kujtim Hoxha created

fix(anthropic): better 429/529 handling

Change summary

cmd/root.go                                  |  10 
internal/app/services.go                     |   4 
internal/llm/agent/agent.go                  |  19 +
internal/llm/provider/anthropic.go           | 234 +++++++++++++++------
internal/llm/provider/provider.go            |   5 
internal/tui/components/core/status.go       |  48 ++--
internal/tui/components/dialog/permission.go |  96 ++++----
internal/tui/components/repl/editor.go       |   2 
internal/tui/tui.go                          |   8 
internal/tui/util/util.go                    |  33 ++
10 files changed, 307 insertions(+), 152 deletions(-)

Detailed changes

cmd/root.go 🔗

@@ -107,6 +107,16 @@ func setupSubscriptions(app *app.App) (chan tea.Msg, func()) {
 			wg.Done()
 		}()
 	}
+	{
+		sub := app.Status.Subscribe(ctx)
+		wg.Add(1)
+		go func() {
+			for ev := range sub {
+				ch <- ev
+			}
+			wg.Done()
+		}()
+	}
 	return ch, func() {
 		cancel()
 		wg.Wait()

internal/app/services.go 🔗

@@ -11,7 +11,9 @@ import (
 	"github.com/kujtimiihoxha/termai/internal/lsp/watcher"
 	"github.com/kujtimiihoxha/termai/internal/message"
 	"github.com/kujtimiihoxha/termai/internal/permission"
+	"github.com/kujtimiihoxha/termai/internal/pubsub"
 	"github.com/kujtimiihoxha/termai/internal/session"
+	"github.com/kujtimiihoxha/termai/internal/tui/util"
 )
 
 type App struct {
@@ -25,6 +27,7 @@ type App struct {
 
 	Logger logging.Interface
 
+	Status  *pubsub.Broker[util.InfoMsg]
 	ceanups []func()
 }
 
@@ -43,6 +46,7 @@ func New(ctx context.Context, conn *sql.DB) *App {
 		Messages:    messages,
 		Permissions: permission.NewPermissionService(),
 		Logger:      log,
+		Status:      pubsub.NewBroker[util.InfoMsg](),
 		LSPClients:  make(map[string]*lsp.Client),
 	}
 

internal/llm/agent/agent.go 🔗

@@ -15,6 +15,8 @@ import (
 	"github.com/kujtimiihoxha/termai/internal/llm/provider"
 	"github.com/kujtimiihoxha/termai/internal/llm/tools"
 	"github.com/kujtimiihoxha/termai/internal/message"
+	"github.com/kujtimiihoxha/termai/internal/pubsub"
+	"github.com/kujtimiihoxha/termai/internal/tui/util"
 )
 
 type Agent interface {
@@ -92,9 +94,24 @@ func (c *agent) processEvent(
 		assistantMsg.AppendContent(event.Content)
 		return c.Messages.Update(*assistantMsg)
 	case provider.EventError:
+		// TODO: remove when realease
 		log.Println("error", event.Error)
+		c.App.Status.Publish(pubsub.UpdatedEvent, util.InfoMsg{
+			Type: util.InfoTypeError,
+			Msg:  event.Error.Error(),
+		})
 		return event.Error
-
+	case provider.EventWarning:
+		c.App.Status.Publish(pubsub.UpdatedEvent, util.InfoMsg{
+			Type: util.InfoTypeWarn,
+			Msg:  event.Info,
+		})
+		return nil
+	case provider.EventInfo:
+		c.App.Status.Publish(pubsub.UpdatedEvent, util.InfoMsg{
+			Type: util.InfoTypeInfo,
+			Msg:  event.Info,
+		})
 	case provider.EventComplete:
 		assistantMsg.SetToolCalls(event.Response.ToolCalls)
 		assistantMsg.AddFinish(event.Response.FinishReason)

internal/llm/provider/anthropic.go 🔗

@@ -4,7 +4,9 @@ import (
 	"context"
 	"encoding/json"
 	"errors"
+	"fmt"
 	"strings"
+	"time"
 
 	"github.com/anthropics/anthropic-sdk-go"
 	"github.com/anthropics/anthropic-sdk-go/option"
@@ -68,21 +70,24 @@ func (a *anthropicProvider) SendMessages(ctx context.Context, messages []message
 	anthropicMessages := a.convertToAnthropicMessages(messages)
 	anthropicTools := a.convertToAnthropicTools(tools)
 
-	response, err := a.client.Messages.New(ctx, anthropic.MessageNewParams{
-		Model:       anthropic.Model(a.model.APIModel),
-		MaxTokens:   a.maxTokens,
-		Temperature: anthropic.Float(0),
-		Messages:    anthropicMessages,
-		Tools:       anthropicTools,
-		System: []anthropic.TextBlockParam{
-			{
-				Text: a.systemMessage,
-				CacheControl: anthropic.CacheControlEphemeralParam{
-					Type: "ephemeral",
+	response, err := a.client.Messages.New(
+		ctx,
+		anthropic.MessageNewParams{
+			Model:       anthropic.Model(a.model.APIModel),
+			MaxTokens:   a.maxTokens,
+			Temperature: anthropic.Float(0),
+			Messages:    anthropicMessages,
+			Tools:       anthropicTools,
+			System: []anthropic.TextBlockParam{
+				{
+					Text: a.systemMessage,
+					CacheControl: anthropic.CacheControlEphemeralParam{
+						Type: "ephemeral",
+					},
 				},
 			},
 		},
-	})
+	)
 	if err != nil {
 		return nil, err
 	}
@@ -121,83 +126,171 @@ func (a *anthropicProvider) StreamResponse(ctx context.Context, messages []messa
 		temperature = anthropic.Float(1)
 	}
 
-	stream := a.client.Messages.NewStreaming(ctx, anthropic.MessageNewParams{
-		Model:       anthropic.Model(a.model.APIModel),
-		MaxTokens:   a.maxTokens,
-		Temperature: temperature,
-		Messages:    anthropicMessages,
-		Tools:       anthropicTools,
-		Thinking:    thinkingParam,
-		System: []anthropic.TextBlockParam{
-			{
-				Text: a.systemMessage,
-				CacheControl: anthropic.CacheControlEphemeralParam{
-					Type: "ephemeral",
-				},
-			},
-		},
-	})
-
 	eventChan := make(chan ProviderEvent)
 
 	go func() {
 		defer close(eventChan)
 
-		accumulatedMessage := anthropic.Message{}
+		const maxRetries = 8
+		attempts := 0
 
-		for stream.Next() {
-			event := stream.Current()
-			err := accumulatedMessage.Accumulate(event)
-			if err != nil {
-				eventChan <- ProviderEvent{Type: EventError, Error: err}
-				return
+		for {
+			// If this isn't the first attempt, we're retrying
+			if attempts > 0 {
+				if attempts > maxRetries {
+					eventChan <- ProviderEvent{
+						Type:  EventError,
+						Error: errors.New("maximum retry attempts reached for rate limit (429)"),
+					}
+					return
+				}
+
+				// Inform user we're retrying with attempt number
+				eventChan <- ProviderEvent{
+					Type: EventWarning,
+					Info: fmt.Sprintf("[Retrying due to rate limit... attempt %d of %d]", attempts, maxRetries),
+				}
+
+				// Calculate backoff with exponential backoff and jitter
+				backoffMs := 2000 * (1 << (attempts - 1)) // 2s, 4s, 8s, 16s, 32s
+				jitterMs := int(float64(backoffMs) * 0.2)
+				totalBackoffMs := backoffMs + jitterMs
+
+				// Sleep with backoff, respecting context cancellation
+				select {
+				case <-ctx.Done():
+					eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
+					return
+				case <-time.After(time.Duration(totalBackoffMs) * time.Millisecond):
+					// Continue with retry
+				}
 			}
 
-			switch event := event.AsAny().(type) {
-			case anthropic.ContentBlockStartEvent:
-				eventChan <- ProviderEvent{Type: EventContentStart}
+			attempts++
+
+			// Create new streaming request
+			stream := a.client.Messages.NewStreaming(
+				ctx,
+				anthropic.MessageNewParams{
+					Model:       anthropic.Model(a.model.APIModel),
+					MaxTokens:   a.maxTokens,
+					Temperature: temperature,
+					Messages:    anthropicMessages,
+					Tools:       anthropicTools,
+					Thinking:    thinkingParam,
+					System: []anthropic.TextBlockParam{
+						{
+							Text: a.systemMessage,
+							CacheControl: anthropic.CacheControlEphemeralParam{
+								Type: "ephemeral",
+							},
+						},
+					},
+				},
+			)
 
-			case anthropic.ContentBlockDeltaEvent:
-				if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" {
-					eventChan <- ProviderEvent{
-						Type:     EventThinkingDelta,
-						Thinking: event.Delta.Thinking,
+			// Process stream events
+			accumulatedMessage := anthropic.Message{}
+			streamSuccess := false
+
+			// Process the stream until completion or error
+			for stream.Next() {
+				event := stream.Current()
+				err := accumulatedMessage.Accumulate(event)
+				if err != nil {
+					eventChan <- ProviderEvent{Type: EventError, Error: err}
+					return // Don't retry on accumulation errors
+				}
+
+				switch event := event.AsAny().(type) {
+				case anthropic.ContentBlockStartEvent:
+					eventChan <- ProviderEvent{Type: EventContentStart}
+
+				case anthropic.ContentBlockDeltaEvent:
+					if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" {
+						eventChan <- ProviderEvent{
+							Type:     EventThinkingDelta,
+							Thinking: event.Delta.Thinking,
+						}
+					} else if event.Delta.Type == "text_delta" && event.Delta.Text != "" {
+						eventChan <- ProviderEvent{
+							Type:    EventContentDelta,
+							Content: event.Delta.Text,
+						}
 					}
-				} else if event.Delta.Type == "text_delta" && event.Delta.Text != "" {
-					eventChan <- ProviderEvent{
-						Type:    EventContentDelta,
-						Content: event.Delta.Text,
+
+				case anthropic.ContentBlockStopEvent:
+					eventChan <- ProviderEvent{Type: EventContentStop}
+
+				case anthropic.MessageStopEvent:
+					streamSuccess = true
+					content := ""
+					for _, block := range accumulatedMessage.Content {
+						if text, ok := block.AsAny().(anthropic.TextBlock); ok {
+							content += text.Text
+						}
 					}
-				}
 
-			case anthropic.ContentBlockStopEvent:
-				eventChan <- ProviderEvent{Type: EventContentStop}
+					toolCalls := a.extractToolCalls(accumulatedMessage.Content)
+					tokenUsage := a.extractTokenUsage(accumulatedMessage.Usage)
 
-			case anthropic.MessageStopEvent:
-				content := ""
-				for _, block := range accumulatedMessage.Content {
-					if text, ok := block.AsAny().(anthropic.TextBlock); ok {
-						content += text.Text
+					eventChan <- ProviderEvent{
+						Type: EventComplete,
+						Response: &ProviderResponse{
+							Content:      content,
+							ToolCalls:    toolCalls,
+							Usage:        tokenUsage,
+							FinishReason: string(accumulatedMessage.StopReason),
+						},
 					}
 				}
+			}
 
-				toolCalls := a.extractToolCalls(accumulatedMessage.Content)
-				tokenUsage := a.extractTokenUsage(accumulatedMessage.Usage)
+			// If the stream completed successfully, we're done
+			if streamSuccess {
+				return
+			}
 
-				eventChan <- ProviderEvent{
-					Type: EventComplete,
-					Response: &ProviderResponse{
-						Content:      content,
-						ToolCalls:    toolCalls,
-						Usage:        tokenUsage,
-						FinishReason: string(accumulatedMessage.StopReason),
-					},
+			// Check for stream errors
+			err := stream.Err()
+			if err != nil {
+				var apierr *anthropic.Error
+				if errors.As(err, &apierr) {
+					if apierr.StatusCode == 429 || apierr.StatusCode == 529 {
+						// Check for Retry-After header
+						if retryAfterValues := apierr.Response.Header.Values("Retry-After"); len(retryAfterValues) > 0 {
+							// Parse the retry after value (seconds)
+							var retryAfterSec int
+							if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryAfterSec); err == nil {
+								retryMs := retryAfterSec * 1000
+
+								// Inform user of retry with specific wait time
+								eventChan <- ProviderEvent{
+									Type: EventWarning,
+									Info: fmt.Sprintf("[Rate limited: waiting %d seconds as specified by API]", retryAfterSec),
+								}
+
+								// Sleep respecting context cancellation
+								select {
+								case <-ctx.Done():
+									eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
+									return
+								case <-time.After(time.Duration(retryMs) * time.Millisecond):
+									// Continue with retry after specified delay
+									continue
+								}
+							}
+						}
+
+						// Fall back to exponential backoff if Retry-After parsing failed
+						continue
+					}
 				}
-			}
-		}
 
-		if stream.Err() != nil {
-			eventChan <- ProviderEvent{Type: EventError, Error: stream.Err()}
+				// For non-rate limit errors, report and exit
+				eventChan <- ProviderEvent{Type: EventError, Error: err}
+				return
+			}
 		}
 	}()
 
@@ -311,3 +404,4 @@ func (a *anthropicProvider) convertToAnthropicMessages(messages []message.Messag
 
 	return anthropicMessages
 }
+

internal/llm/provider/provider.go 🔗

@@ -17,6 +17,8 @@ const (
 	EventContentStop   EventType = "content_stop"
 	EventComplete      EventType = "complete"
 	EventError         EventType = "error"
+	EventWarning       EventType = "warning"
+	EventInfo          EventType = "info"
 )
 
 type TokenUsage struct {
@@ -40,6 +42,9 @@ type ProviderEvent struct {
 	ToolCall *message.ToolCall
 	Error    error
 	Response *ProviderResponse
+
+	// Used for giving users info on e.x retry
+	Info string
 }
 
 type Provider interface {

internal/tui/components/core/status.go 🔗

@@ -7,16 +7,16 @@ import (
 	"github.com/charmbracelet/lipgloss"
 	"github.com/kujtimiihoxha/termai/internal/config"
 	"github.com/kujtimiihoxha/termai/internal/llm/models"
+	"github.com/kujtimiihoxha/termai/internal/pubsub"
 	"github.com/kujtimiihoxha/termai/internal/tui/styles"
 	"github.com/kujtimiihoxha/termai/internal/tui/util"
 	"github.com/kujtimiihoxha/termai/internal/version"
 )
 
 type statusCmp struct {
-	err         error
-	info        string
-	width       int
-	messageTTL  time.Duration
+	info       *util.InfoMsg
+	width      int
+	messageTTL time.Duration
 }
 
 // clearMessageCmd is a command that clears status messages after a timeout
@@ -34,17 +34,15 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		m.width = msg.Width
-	case util.ErrorMsg:
-		m.err = msg
-		m.info = ""
+		return m, m.clearMessageCmd()
+	case pubsub.Event[util.InfoMsg]:
+		m.info = &msg.Payload
 		return m, m.clearMessageCmd()
 	case util.InfoMsg:
-		m.info = string(msg)
-		m.err = nil
+		m.info = &msg
 		return m, m.clearMessageCmd()
 	case util.ClearStatusMsg:
-		m.info = ""
-		m.err = nil
+		m.info = nil
 	}
 	return m, nil
 }
@@ -56,25 +54,25 @@ var (
 
 func (m statusCmp) View() string {
 	status := styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render("? help")
-
-	if m.err != nil {
-		status += styles.Regular.Padding(0, 1).
-			Background(styles.Red).
-			Foreground(styles.Text).
-			Width(m.availableFooterMsgWidth()).
-			Render(m.err.Error())
-	} else if m.info != "" {
-		status += styles.Padded.
+	if m.info != nil {
+		infoStyle := styles.Padded.
 			Foreground(styles.Base).
-			Background(styles.Green).
-			Width(m.availableFooterMsgWidth()).
-			Render(m.info)
+			Width(m.availableFooterMsgWidth())
+		switch m.info.Type {
+		case util.InfoTypeInfo:
+			infoStyle = infoStyle.Background(styles.Blue)
+		case util.InfoTypeWarn:
+			infoStyle = infoStyle.Background(styles.Peach)
+		case util.InfoTypeError:
+			infoStyle = infoStyle.Background(styles.Red)
+		}
+		status += infoStyle.Render(m.info.Msg)
 	} else {
 		status += styles.Padded.
 			Foreground(styles.Base).
 			Background(styles.LightGrey).
 			Width(m.availableFooterMsgWidth()).
-			Render(m.info)
+			Render("")
 	}
 	status += m.model()
 	status += versionWidget
@@ -93,6 +91,6 @@ func (m statusCmp) model() string {
 
 func NewStatusCmp() tea.Model {
 	return &statusCmp{
-		messageTTL: 5 * time.Second,
+		messageTTL: 15 * time.Second,
 	}
 }

internal/tui/components/dialog/permission.go 🔗

@@ -69,13 +69,13 @@ type permissionDialogCmp struct {
 func formatDiff(diffText string) string {
 	lines := strings.Split(diffText, "\n")
 	var formattedLines []string
-	
+
 	// Define styles for different line types
 	addStyle := lipgloss.NewStyle().Foreground(styles.Green)
 	removeStyle := lipgloss.NewStyle().Foreground(styles.Red)
 	headerStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Blue)
 	contextStyle := lipgloss.NewStyle().Foreground(styles.SubText0)
-	
+
 	// Process each line
 	for _, line := range lines {
 		if strings.HasPrefix(line, "+") {
@@ -90,7 +90,7 @@ func formatDiff(diffText string) string {
 			formattedLines = append(formattedLines, line)
 		}
 	}
-	
+
 	// Join all formatted lines
 	return strings.Join(formattedLines, "\n")
 }
@@ -112,13 +112,13 @@ func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				p.selectOption.Blur()
 				// Add a visual indicator for focus change
 				cmds = append(cmds, tea.Batch(
-					util.CmdHandler(util.InfoMsg("Viewing content - use arrow keys to scroll")),
+					util.ReportInfo("Viewing content - use arrow keys to scroll"),
 				))
 			} else {
 				p.selectOption.Focus()
 				// Add a visual indicator for focus change
 				cmds = append(cmds, tea.Batch(
-					util.CmdHandler(util.InfoMsg("Select an action")),
+					util.CmdHandler(util.ReportInfo("Select an action")),
 				))
 			}
 			return p, tea.Batch(cmds...)
@@ -162,44 +162,44 @@ func (p *permissionDialogCmp) render() string {
 		lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Path:"), " ", valueStyle.Render(p.permission.Path)),
 		" ",
 	}
-	
+
 	// Create the header content first so it can be used in all cases
 	headerContent := lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
-	
+
 	r, _ := glamour.NewTermRenderer(
 		glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
 		glamour.WithWordWrap(p.width-10),
 		glamour.WithEmoji(),
 	)
-	
+
 	// Handle different tool types
 	switch p.permission.ToolName {
 	case tools.BashToolName:
 		pr := p.permission.Params.(tools.BashPermissionsParams)
 		headerParts = append(headerParts, keyStyle.Render("Command:"))
 		content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
-		
+
 		renderedContent, _ := r.Render(content)
 		p.contentViewPort.Width = p.width - 2 - 2
-		
+
 		// Calculate content height dynamically based on content
 		contentLines := len(strings.Split(renderedContent, "\n"))
 		// Set a reasonable min/max for the viewport height
 		minContentHeight := 3
 		maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
-		
+
 		// Add some padding to the content lines
 		contentHeight := contentLines + 2
 		contentHeight = max(contentHeight, minContentHeight)
 		contentHeight = min(contentHeight, maxContentHeight)
 		p.contentViewPort.Height = contentHeight
-		
+
 		p.contentViewPort.SetContent(renderedContent)
-		
+
 		// Style the viewport
 		var contentBorder lipgloss.Border
 		var borderColor lipgloss.TerminalColor
-		
+
 		if p.isViewportFocus {
 			contentBorder = lipgloss.DoubleBorder()
 			borderColor = styles.Blue
@@ -207,47 +207,47 @@ func (p *permissionDialogCmp) render() string {
 			contentBorder = lipgloss.RoundedBorder()
 			borderColor = styles.Flamingo
 		}
-		
+
 		contentStyle := lipgloss.NewStyle().
 			MarginTop(1).
 			Padding(0, 1).
 			Border(contentBorder).
 			BorderForeground(borderColor)
-		
+
 		if p.isViewportFocus {
 			contentStyle = contentStyle.BorderBackground(styles.Surface0)
 		}
-		
+
 		contentFinal := contentStyle.Render(p.contentViewPort.View())
-		
+
 		return lipgloss.JoinVertical(
 			lipgloss.Top,
 			headerContent,
 			contentFinal,
 			form,
 		)
-		
+
 	case tools.EditToolName:
 		pr := p.permission.Params.(tools.EditPermissionsParams)
 		headerParts = append(headerParts, keyStyle.Render("Update"))
 		// Recreate header content with the updated headerParts
 		headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
-		
+
 		// Format the diff with colors
 		formattedDiff := formatDiff(pr.Diff)
-		
+
 		// Set up viewport for the diff content
 		p.contentViewPort.Width = p.width - 2 - 2
-		
+
 		// Calculate content height dynamically based on window size
 		maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
 		p.contentViewPort.Height = maxContentHeight
 		p.contentViewPort.SetContent(formattedDiff)
-		
+
 		// Style the viewport
 		var contentBorder lipgloss.Border
 		var borderColor lipgloss.TerminalColor
-		
+
 		if p.isViewportFocus {
 			contentBorder = lipgloss.DoubleBorder()
 			borderColor = styles.Blue
@@ -255,47 +255,47 @@ func (p *permissionDialogCmp) render() string {
 			contentBorder = lipgloss.RoundedBorder()
 			borderColor = styles.Flamingo
 		}
-		
+
 		contentStyle := lipgloss.NewStyle().
 			MarginTop(1).
 			Padding(0, 1).
 			Border(contentBorder).
 			BorderForeground(borderColor)
-		
+
 		if p.isViewportFocus {
 			contentStyle = contentStyle.BorderBackground(styles.Surface0)
 		}
-		
+
 		contentFinal := contentStyle.Render(p.contentViewPort.View())
-		
+
 		return lipgloss.JoinVertical(
 			lipgloss.Top,
 			headerContent,
 			contentFinal,
 			form,
 		)
-		
+
 	case tools.WriteToolName:
 		pr := p.permission.Params.(tools.WritePermissionsParams)
 		headerParts = append(headerParts, keyStyle.Render("Content"))
 		// Recreate header content with the updated headerParts
 		headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
-		
+
 		// Format the diff with colors
 		formattedDiff := formatDiff(pr.Content)
-		
+
 		// Set up viewport for the content
 		p.contentViewPort.Width = p.width - 2 - 2
-		
+
 		// Calculate content height dynamically based on window size
 		maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
 		p.contentViewPort.Height = maxContentHeight
 		p.contentViewPort.SetContent(formattedDiff)
-		
+
 		// Style the viewport
 		var contentBorder lipgloss.Border
 		var borderColor lipgloss.TerminalColor
-		
+
 		if p.isViewportFocus {
 			contentBorder = lipgloss.DoubleBorder()
 			borderColor = styles.Blue
@@ -303,75 +303,75 @@ func (p *permissionDialogCmp) render() string {
 			contentBorder = lipgloss.RoundedBorder()
 			borderColor = styles.Flamingo
 		}
-		
+
 		contentStyle := lipgloss.NewStyle().
 			MarginTop(1).
 			Padding(0, 1).
 			Border(contentBorder).
 			BorderForeground(borderColor)
-		
+
 		if p.isViewportFocus {
 			contentStyle = contentStyle.BorderBackground(styles.Surface0)
 		}
-		
+
 		contentFinal := contentStyle.Render(p.contentViewPort.View())
-		
+
 		return lipgloss.JoinVertical(
 			lipgloss.Top,
 			headerContent,
 			contentFinal,
 			form,
 		)
-		
+
 	case tools.FetchToolName:
 		pr := p.permission.Params.(tools.FetchPermissionsParams)
 		headerParts = append(headerParts, keyStyle.Render("URL: "+pr.URL))
 		content := p.permission.Description
-		
+
 		renderedContent, _ := r.Render(content)
 		p.contentViewPort.Width = p.width - 2 - 2
 		p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
 		p.contentViewPort.SetContent(renderedContent)
-		
+
 		// Style the viewport
 		contentStyle := lipgloss.NewStyle().
 			MarginTop(1).
 			Padding(0, 1).
 			Border(lipgloss.RoundedBorder()).
 			BorderForeground(styles.Flamingo)
-		
+
 		contentFinal := contentStyle.Render(p.contentViewPort.View())
 		if renderedContent == "" {
 			contentFinal = ""
 		}
-		
+
 		return lipgloss.JoinVertical(
 			lipgloss.Top,
 			headerContent,
 			contentFinal,
 			form,
 		)
-		
+
 	default:
 		content := p.permission.Description
-		
+
 		renderedContent, _ := r.Render(content)
 		p.contentViewPort.Width = p.width - 2 - 2
 		p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
 		p.contentViewPort.SetContent(renderedContent)
-		
+
 		// Style the viewport
 		contentStyle := lipgloss.NewStyle().
 			MarginTop(1).
 			Padding(0, 1).
 			Border(lipgloss.RoundedBorder()).
 			BorderForeground(styles.Flamingo)
-		
+
 		contentFinal := contentStyle.Render(p.contentViewPort.View())
 		if renderedContent == "" {
 			contentFinal = ""
 		}
-		
+
 		return lipgloss.JoinVertical(
 			lipgloss.Top,
 			headerContent,

internal/tui/components/repl/editor.go 🔗

@@ -140,7 +140,7 @@ func (m *editorCmp) Send() tea.Cmd {
 	return func() tea.Msg {
 		messages, _ := m.app.Messages.List(m.sessionID)
 		if hasUnfinishedMessages(messages) {
-			return util.InfoMsg("Assistant is still working on the previous message")
+			return util.ReportWarn("Assistant is still working on the previous message")
 		}
 		a, _ := agent.NewCoderAgent(m.app)
 

internal/tui/tui.go 🔗

@@ -77,6 +77,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case pubsub.Event[permission.PermissionRequest]:
 		return a, dialog.NewPermissionDialogCmd(msg.Payload)
+	case pubsub.Event[util.InfoMsg]:
+		a.status, _ = a.status.Update(msg)
 	case dialog.PermissionResponseMsg:
 		switch msg.Action {
 		case dialog.PermissionAllow:
@@ -121,8 +123,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return a, a.moveToPage(msg.ID)
 	case util.InfoMsg:
 		a.status, _ = a.status.Update(msg)
-	case util.ErrorMsg:
-		a.status, _ = a.status.Update(msg)
 	case tea.KeyMsg:
 		if a.editorMode == vimtea.ModeNormal {
 			switch {
@@ -141,7 +141,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				if a.currentPage == page.ReplPage {
 					sessions, err := a.app.Sessions.List()
 					if err != nil {
-						return a, util.CmdHandler(util.ErrorMsg(err))
+						return a, util.CmdHandler(util.ReportError(err))
 					}
 					lastSession := sessions[0]
 					if lastSession.MessageCount == 0 {
@@ -149,7 +149,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 					}
 					s, err := a.app.Sessions.Create("New Session")
 					if err != nil {
-						return a, util.CmdHandler(util.ErrorMsg(err))
+						return a, util.CmdHandler(util.ReportError(err))
 					}
 					return a, util.CmdHandler(repl.SelectedSessionMsg{SessionID: s.ID})
 				}

internal/tui/util/util.go 🔗

@@ -9,12 +9,39 @@ func CmdHandler(msg tea.Msg) tea.Cmd {
 }
 
 func ReportError(err error) tea.Cmd {
-	return CmdHandler(ErrorMsg(err))
+	return CmdHandler(InfoMsg{
+		Type: InfoTypeError,
+		Msg:  err.Error(),
+	})
+}
+
+type InfoType int
+
+const (
+	InfoTypeInfo InfoType = iota
+	InfoTypeWarn
+	InfoTypeError
+)
+
+func ReportInfo(info string) tea.Cmd {
+	return CmdHandler(InfoMsg{
+		Type: InfoTypeInfo,
+		Msg:  info,
+	})
+}
+
+func ReportWarn(warn string) tea.Cmd {
+	return CmdHandler(InfoMsg{
+		Type: InfoTypeWarn,
+		Msg:  warn,
+	})
 }
 
 type (
-	InfoMsg       string
-	ErrorMsg      error
+	InfoMsg struct {
+		Type InfoType
+		Msg  string
+	}
 	ClearStatusMsg struct{}
 )