on main help sign in
Merge pull request #22 from adamdottv/adam/retries

fix(anthropic): better 429/529 handling

commitfde04bbf85ea641a33a282b354d63f227f9945fb authorKujtim Hoxha <kujtimii.h@gmail.com> dateTue 8 Apr 2025, 20:32 (2 years ago) parent124bd574385fb3
10 files changed+307−152
cmd/root.go+10 internal/app/services.go+4 internal/llm/agent/agent.go+18−1 internal/llm/provider/anthropic.go+164−70 internal/llm/provider/provider.go+5 internal/tui/components/core/status.go+23−25 internal/tui/components/dialog/permission.go+48−48 internal/tui/components/repl/editor.go+1−1 internal/tui/tui.go+4−4 internal/tui/util/util.go+30−3
cmd/root.go+10
@@ -107,6 +107,16 @@ func setupSubscriptions(app *app.App) (chan tea.Msg, func()) {
107107 wg.Done()
108108 }()
109109 }
110+ {
111+ sub := app.Status.Subscribe(ctx)
112+ wg.Add(1)
113+ go func() {
114+ for ev := range sub {
115+ ch <- ev
116+ }
117+ wg.Done()
118+ }()
119+ }
110120 return ch, func() {
111121 cancel()
112122 wg.Wait()
internal/app/services.go+4
@@ -11,7 +11,9 @@ import (
1111 "github.com/kujtimiihoxha/termai/internal/lsp/watcher"
1212 "github.com/kujtimiihoxha/termai/internal/message"
1313 "github.com/kujtimiihoxha/termai/internal/permission"
14+ "github.com/kujtimiihoxha/termai/internal/pubsub"
1415 "github.com/kujtimiihoxha/termai/internal/session"
16+ "github.com/kujtimiihoxha/termai/internal/tui/util"
1517)
1618
1719type App struct {
@@ -25,6 +27,7 @@ type App struct {
2527
2628 Logger logging.Interface
2729
30+ Status *pubsub.Broker[util.InfoMsg]
2831 ceanups []func()
2932}
3033
@@ -43,6 +46,7 @@ func New(ctx context.Context, conn *sql.DB) *App {
4346 Messages: messages,
4447 Permissions: permission.NewPermissionService(),
4548 Logger: log,
49+ Status: pubsub.NewBroker[util.InfoMsg](),
4650 LSPClients: make(map[string]*lsp.Client),
4751 }
4852
internal/llm/agent/agent.go+18−1
@@ -15,6 +15,8 @@ import (
1515 "github.com/kujtimiihoxha/termai/internal/llm/provider"
1616 "github.com/kujtimiihoxha/termai/internal/llm/tools"
1717 "github.com/kujtimiihoxha/termai/internal/message"
18+ "github.com/kujtimiihoxha/termai/internal/pubsub"
19+ "github.com/kujtimiihoxha/termai/internal/tui/util"
1820)
1921
2022type Agent interface {
@@ -92,9 +94,24 @@ func (c *agent) processEvent(
9294 assistantMsg.AppendContent(event.Content)
9395 return c.Messages.Update(*assistantMsg)
9496 case provider.EventError:
97+ // TODO: remove when realease
9598 log.Println("error", event.Error)
99+ c.App.Status.Publish(pubsub.UpdatedEvent, util.InfoMsg{
100+ Type: util.InfoTypeError,
101+ Msg: event.Error.Error(),
102+ })
96103 return event.Error
97−
104+ case provider.EventWarning:
105+ c.App.Status.Publish(pubsub.UpdatedEvent, util.InfoMsg{
106+ Type: util.InfoTypeWarn,
107+ Msg: event.Info,
108+ })
109+ return nil
110+ case provider.EventInfo:
111+ c.App.Status.Publish(pubsub.UpdatedEvent, util.InfoMsg{
112+ Type: util.InfoTypeInfo,
113+ Msg: event.Info,
114+ })
98115 case provider.EventComplete:
99116 assistantMsg.SetToolCalls(event.Response.ToolCalls)
100117 assistantMsg.AddFinish(event.Response.FinishReason)
internal/llm/provider/anthropic.go+164−70
@@ -4,7 +4,9 @@ import (
44 "context"
55 "encoding/json"
66 "errors"
7+ "fmt"
78 "strings"
9+ "time"
810
911 "github.com/anthropics/anthropic-sdk-go"
1012 "github.com/anthropics/anthropic-sdk-go/option"
@@ -68,21 +70,24 @@ func (a *anthropicProvider) SendMessages(ctx context.Context, messages []message
6870 anthropicMessages := a.convertToAnthropicMessages(messages)
6971 anthropicTools := a.convertToAnthropicTools(tools)
7072
71− response, err := a.client.Messages.New(ctx, anthropic.MessageNewParams{
72− Model: anthropic.Model(a.model.APIModel),
73− MaxTokens: a.maxTokens,
74− Temperature: anthropic.Float(0),
75− Messages: anthropicMessages,
76− Tools: anthropicTools,
77− System: []anthropic.TextBlockParam{
78− {
79− Text: a.systemMessage,
80− CacheControl: anthropic.CacheControlEphemeralParam{
81− Type: "ephemeral",
73+ response, err := a.client.Messages.New(
74+ ctx,
75+ anthropic.MessageNewParams{
76+ Model: anthropic.Model(a.model.APIModel),
77+ MaxTokens: a.maxTokens,
78+ Temperature: anthropic.Float(0),
79+ Messages: anthropicMessages,
80+ Tools: anthropicTools,
81+ System: []anthropic.TextBlockParam{
82+ {
83+ Text: a.systemMessage,
84+ CacheControl: anthropic.CacheControlEphemeralParam{
85+ Type: "ephemeral",
86+ },
8287 },
8388 },
8489 },
85− })
90+ )
8691 if err != nil {
8792 return nil, err
8893 }
@@ -121,83 +126,171 @@ func (a *anthropicProvider) StreamResponse(ctx context.Context, messages []messa
121126 temperature = anthropic.Float(1)
122127 }
123128
124− stream := a.client.Messages.NewStreaming(ctx, anthropic.MessageNewParams{
125− Model: anthropic.Model(a.model.APIModel),
126− MaxTokens: a.maxTokens,
127− Temperature: temperature,
128− Messages: anthropicMessages,
129− Tools: anthropicTools,
130− Thinking: thinkingParam,
131− System: []anthropic.TextBlockParam{
132− {
133− Text: a.systemMessage,
134− CacheControl: anthropic.CacheControlEphemeralParam{
135− Type: "ephemeral",
136− },
137− },
138− },
139− })
140−
141129 eventChan := make(chan ProviderEvent)
142130
143131 go func() {
144132 defer close(eventChan)
145133
146− accumulatedMessage := anthropic.Message{}
134+ const maxRetries = 8
135+ attempts := 0
147136
148− for stream.Next() {
149− event := stream.Current()
150− err := accumulatedMessage.Accumulate(event)
151− if err != nil {
152− eventChan <- ProviderEvent{Type: EventError, Error: err}
153− return
137+ for {
138+ // If this isn't the first attempt, we're retrying
139+ if attempts > 0 {
140+ if attempts > maxRetries {
141+ eventChan <- ProviderEvent{
142+ Type: EventError,
143+ Error: errors.New("maximum retry attempts reached for rate limit (429)"),
144+ }
145+ return
146+ }
147+
148+ // Inform user we're retrying with attempt number
149+ eventChan <- ProviderEvent{
150+ Type: EventWarning,
151+ Info: fmt.Sprintf("[Retrying due to rate limit... attempt %d of %d]", attempts, maxRetries),
152+ }
153+
154+ // Calculate backoff with exponential backoff and jitter
155+ backoffMs := 2000 * (1 << (attempts - 1)) // 2s, 4s, 8s, 16s, 32s
156+ jitterMs := int(float64(backoffMs) * 0.2)
157+ totalBackoffMs := backoffMs + jitterMs
158+
159+ // Sleep with backoff, respecting context cancellation
160+ select {
161+ case <-ctx.Done():
162+ eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
163+ return
164+ case <-time.After(time.Duration(totalBackoffMs) * time.Millisecond):
165+ // Continue with retry
166+ }
154167 }
155168
156− switch event := event.AsAny().(type) {
157− case anthropic.ContentBlockStartEvent:
158− eventChan <- ProviderEvent{Type: EventContentStart}
169+ attempts++
170+
171+ // Create new streaming request
172+ stream := a.client.Messages.NewStreaming(
173+ ctx,
174+ anthropic.MessageNewParams{
175+ Model: anthropic.Model(a.model.APIModel),
176+ MaxTokens: a.maxTokens,
177+ Temperature: temperature,
178+ Messages: anthropicMessages,
179+ Tools: anthropicTools,
180+ Thinking: thinkingParam,
181+ System: []anthropic.TextBlockParam{
182+ {
183+ Text: a.systemMessage,
184+ CacheControl: anthropic.CacheControlEphemeralParam{
185+ Type: "ephemeral",
186+ },
187+ },
188+ },
189+ },
190+ )
159191
160− case anthropic.ContentBlockDeltaEvent:
161− if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" {
162− eventChan <- ProviderEvent{
163− Type: EventThinkingDelta,
164− Thinking: event.Delta.Thinking,
192+ // Process stream events
193+ accumulatedMessage := anthropic.Message{}
194+ streamSuccess := false
195+
196+ // Process the stream until completion or error
197+ for stream.Next() {
198+ event := stream.Current()
199+ err := accumulatedMessage.Accumulate(event)
200+ if err != nil {
201+ eventChan <- ProviderEvent{Type: EventError, Error: err}
202+ return // Don't retry on accumulation errors
203+ }
204+
205+ switch event := event.AsAny().(type) {
206+ case anthropic.ContentBlockStartEvent:
207+ eventChan <- ProviderEvent{Type: EventContentStart}
208+
209+ case anthropic.ContentBlockDeltaEvent:
210+ if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" {
211+ eventChan <- ProviderEvent{
212+ Type: EventThinkingDelta,
213+ Thinking: event.Delta.Thinking,
214+ }
215+ } else if event.Delta.Type == "text_delta" && event.Delta.Text != "" {
216+ eventChan <- ProviderEvent{
217+ Type: EventContentDelta,
218+ Content: event.Delta.Text,
219+ }
165220 }
166− } else if event.Delta.Type == "text_delta" && event.Delta.Text != "" {
167− eventChan <- ProviderEvent{
168− Type: EventContentDelta,
169− Content: event.Delta.Text,
221+
222+ case anthropic.ContentBlockStopEvent:
223+ eventChan <- ProviderEvent{Type: EventContentStop}
224+
225+ case anthropic.MessageStopEvent:
226+ streamSuccess = true
227+ content := ""
228+ for _, block := range accumulatedMessage.Content {
229+ if text, ok := block.AsAny().(anthropic.TextBlock); ok {
230+ content += text.Text
231+ }
170232 }
171− }
172233
173− case anthropic.ContentBlockStopEvent:
174− eventChan <- ProviderEvent{Type: EventContentStop}
234+ toolCalls := a.extractToolCalls(accumulatedMessage.Content)
235+ tokenUsage := a.extractTokenUsage(accumulatedMessage.Usage)
175236
176− case anthropic.MessageStopEvent:
177− content := ""
178− for _, block := range accumulatedMessage.Content {
179− if text, ok := block.AsAny().(anthropic.TextBlock); ok {
180− content += text.Text
237+ eventChan <- ProviderEvent{
238+ Type: EventComplete,
239+ Response: &ProviderResponse{
240+ Content: content,
241+ ToolCalls: toolCalls,
242+ Usage: tokenUsage,
243+ FinishReason: string(accumulatedMessage.StopReason),
244+ },
181245 }
182246 }
247+ }
183248
184− toolCalls := a.extractToolCalls(accumulatedMessage.Content)
185− tokenUsage := a.extractTokenUsage(accumulatedMessage.Usage)
249+ // If the stream completed successfully, we're done
250+ if streamSuccess {
251+ return
252+ }
186253
187− eventChan <- ProviderEvent{
188− Type: EventComplete,
189− Response: &ProviderResponse{
190− Content: content,
191− ToolCalls: toolCalls,
192− Usage: tokenUsage,
193− FinishReason: string(accumulatedMessage.StopReason),
194− },
254+ // Check for stream errors
255+ err := stream.Err()
256+ if err != nil {
257+ var apierr *anthropic.Error
258+ if errors.As(err, &apierr) {
259+ if apierr.StatusCode == 429 || apierr.StatusCode == 529 {
260+ // Check for Retry-After header
261+ if retryAfterValues := apierr.Response.Header.Values("Retry-After"); len(retryAfterValues) > 0 {
262+ // Parse the retry after value (seconds)
263+ var retryAfterSec int
264+ if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryAfterSec); err == nil {
265+ retryMs := retryAfterSec * 1000
266+
267+ // Inform user of retry with specific wait time
268+ eventChan <- ProviderEvent{
269+ Type: EventWarning,
270+ Info: fmt.Sprintf("[Rate limited: waiting %d seconds as specified by API]", retryAfterSec),
271+ }
272+
273+ // Sleep respecting context cancellation
274+ select {
275+ case <-ctx.Done():
276+ eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
277+ return
278+ case <-time.After(time.Duration(retryMs) * time.Millisecond):
279+ // Continue with retry after specified delay
280+ continue
281+ }
282+ }
283+ }
284+
285+ // Fall back to exponential backoff if Retry-After parsing failed
286+ continue
287+ }
195288 }
196− }
197− }
198289
199− if stream.Err() != nil {
200− eventChan <- ProviderEvent{Type: EventError, Error: stream.Err()}
290+ // For non-rate limit errors, report and exit
291+ eventChan <- ProviderEvent{Type: EventError, Error: err}
292+ return
293+ }
201294 }
202295 }()
203296
@@ -311,3 +404,4 @@ func (a *anthropicProvider) convertToAnthropicMessages(messages []message.Messag
311404
312405 return anthropicMessages
313406}
407+
internal/llm/provider/provider.go+5
@@ -17,6 +17,8 @@ const (
1717 EventContentStop EventType = "content_stop"
1818 EventComplete EventType = "complete"
1919 EventError EventType = "error"
20+ EventWarning EventType = "warning"
21+ EventInfo EventType = "info"
2022)
2123
2224type TokenUsage struct {
@@ -40,6 +42,9 @@ type ProviderEvent struct {
4042 ToolCall *message.ToolCall
4143 Error error
4244 Response *ProviderResponse
45+
46+ // Used for giving users info on e.x retry
47+ Info string
4348}
4449
4550type Provider interface {
internal/tui/components/core/status.go+23−25
@@ -7,16 +7,16 @@ import (
77 "github.com/charmbracelet/lipgloss"
88 "github.com/kujtimiihoxha/termai/internal/config"
99 "github.com/kujtimiihoxha/termai/internal/llm/models"
10+ "github.com/kujtimiihoxha/termai/internal/pubsub"
1011 "github.com/kujtimiihoxha/termai/internal/tui/styles"
1112 "github.com/kujtimiihoxha/termai/internal/tui/util"
1213 "github.com/kujtimiihoxha/termai/internal/version"
1314)
1415
1516type statusCmp struct {
16− err error
17− info string
18− width int
19− messageTTL time.Duration
17+ info *util.InfoMsg
18+ width int
19+ messageTTL time.Duration
2020}
2121
2222// 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) {
3434 switch msg := msg.(type) {
3535 case tea.WindowSizeMsg:
3636 m.width = msg.Width
37− case util.ErrorMsg:
38− m.err = msg
39− m.info = ""
37+ return m, m.clearMessageCmd()
38+ case pubsub.Event[util.InfoMsg]:
39+ m.info = &msg.Payload
4040 return m, m.clearMessageCmd()
4141 case util.InfoMsg:
42− m.info = string(msg)
43− m.err = nil
42+ m.info = &msg
4443 return m, m.clearMessageCmd()
4544 case util.ClearStatusMsg:
46− m.info = ""
47− m.err = nil
45+ m.info = nil
4846 }
4947 return m, nil
5048}
@@ -56,25 +54,25 @@ var (
5654
5755func (m statusCmp) View() string {
5856 status := styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render("? help")
59−
60− if m.err != nil {
61− status += styles.Regular.Padding(0, 1).
62− Background(styles.Red).
63− Foreground(styles.Text).
64− Width(m.availableFooterMsgWidth()).
65− Render(m.err.Error())
66− } else if m.info != "" {
67− status += styles.Padded.
57+ if m.info != nil {
58+ infoStyle := styles.Padded.
6859 Foreground(styles.Base).
69− Background(styles.Green).
70− Width(m.availableFooterMsgWidth()).
71− Render(m.info)
60+ Width(m.availableFooterMsgWidth())
61+ switch m.info.Type {
62+ case util.InfoTypeInfo:
63+ infoStyle = infoStyle.Background(styles.Blue)
64+ case util.InfoTypeWarn:
65+ infoStyle = infoStyle.Background(styles.Peach)
66+ case util.InfoTypeError:
67+ infoStyle = infoStyle.Background(styles.Red)
68+ }
69+ status += infoStyle.Render(m.info.Msg)
7270 } else {
7371 status += styles.Padded.
7472 Foreground(styles.Base).
7573 Background(styles.LightGrey).
7674 Width(m.availableFooterMsgWidth()).
77− Render(m.info)
75+ Render("")
7876 }
7977 status += m.model()
8078 status += versionWidget
@@ -93,6 +91,6 @@ func (m statusCmp) model() string {
9391
9492func NewStatusCmp() tea.Model {
9593 return &statusCmp{
96− messageTTL: 5 * time.Second,
94+ messageTTL: 15 * time.Second,
9795 }
9896}
internal/tui/components/dialog/permission.go+48−48
@@ -69,13 +69,13 @@ type permissionDialogCmp struct {
6969func formatDiff(diffText string) string {
7070 lines := strings.Split(diffText, "\n")
7171 var formattedLines []string
72−
72+
7373 // Define styles for different line types
7474 addStyle := lipgloss.NewStyle().Foreground(styles.Green)
7575 removeStyle := lipgloss.NewStyle().Foreground(styles.Red)
7676 headerStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Blue)
7777 contextStyle := lipgloss.NewStyle().Foreground(styles.SubText0)
78−
78+
7979 // Process each line
8080 for _, line := range lines {
8181 if strings.HasPrefix(line, "+") {
@@ -90,7 +90,7 @@ func formatDiff(diffText string) string {
9090 formattedLines = append(formattedLines, line)
9191 }
9292 }
93−
93+
9494 // Join all formatted lines
9595 return strings.Join(formattedLines, "\n")
9696}
@@ -112,13 +112,13 @@ func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
112112 p.selectOption.Blur()
113113 // Add a visual indicator for focus change
114114 cmds = append(cmds, tea.Batch(
115− util.CmdHandler(util.InfoMsg("Viewing content - use arrow keys to scroll")),
115+ util.ReportInfo("Viewing content - use arrow keys to scroll"),
116116 ))
117117 } else {
118118 p.selectOption.Focus()
119119 // Add a visual indicator for focus change
120120 cmds = append(cmds, tea.Batch(
121− util.CmdHandler(util.InfoMsg("Select an action")),
121+ util.CmdHandler(util.ReportInfo("Select an action")),
122122 ))
123123 }
124124 return p, tea.Batch(cmds...)
@@ -162,44 +162,44 @@ func (p *permissionDialogCmp) render() string {
162162 lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Path:"), " ", valueStyle.Render(p.permission.Path)),
163163 " ",
164164 }
165−
165+
166166 // Create the header content first so it can be used in all cases
167167 headerContent := lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
168−
168+
169169 r, _ := glamour.NewTermRenderer(
170170 glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
171171 glamour.WithWordWrap(p.width-10),
172172 glamour.WithEmoji(),
173173 )
174−
174+
175175 // Handle different tool types
176176 switch p.permission.ToolName {
177177 case tools.BashToolName:
178178 pr := p.permission.Params.(tools.BashPermissionsParams)
179179 headerParts = append(headerParts, keyStyle.Render("Command:"))
180180 content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
181−
181+
182182 renderedContent, _ := r.Render(content)
183183 p.contentViewPort.Width = p.width - 2 - 2
184−
184+
185185 // Calculate content height dynamically based on content
186186 contentLines := len(strings.Split(renderedContent, "\n"))
187187 // Set a reasonable min/max for the viewport height
188188 minContentHeight := 3
189189 maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
190−
190+
191191 // Add some padding to the content lines
192192 contentHeight := contentLines + 2
193193 contentHeight = max(contentHeight, minContentHeight)
194194 contentHeight = min(contentHeight, maxContentHeight)
195195 p.contentViewPort.Height = contentHeight
196−
196+
197197 p.contentViewPort.SetContent(renderedContent)
198−
198+
199199 // Style the viewport
200200 var contentBorder lipgloss.Border
201201 var borderColor lipgloss.TerminalColor
202−
202+
203203 if p.isViewportFocus {
204204 contentBorder = lipgloss.DoubleBorder()
205205 borderColor = styles.Blue
@@ -207,47 +207,47 @@ func (p *permissionDialogCmp) render() string {
207207 contentBorder = lipgloss.RoundedBorder()
208208 borderColor = styles.Flamingo
209209 }
210−
210+
211211 contentStyle := lipgloss.NewStyle().
212212 MarginTop(1).
213213 Padding(0, 1).
214214 Border(contentBorder).
215215 BorderForeground(borderColor)
216−
216+
217217 if p.isViewportFocus {
218218 contentStyle = contentStyle.BorderBackground(styles.Surface0)
219219 }
220−
220+
221221 contentFinal := contentStyle.Render(p.contentViewPort.View())
222−
222+
223223 return lipgloss.JoinVertical(
224224 lipgloss.Top,
225225 headerContent,
226226 contentFinal,
227227 form,
228228 )
229−
229+
230230 case tools.EditToolName:
231231 pr := p.permission.Params.(tools.EditPermissionsParams)
232232 headerParts = append(headerParts, keyStyle.Render("Update"))
233233 // Recreate header content with the updated headerParts
234234 headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
235−
235+
236236 // Format the diff with colors
237237 formattedDiff := formatDiff(pr.Diff)
238−
238+
239239 // Set up viewport for the diff content
240240 p.contentViewPort.Width = p.width - 2 - 2
241−
241+
242242 // Calculate content height dynamically based on window size
243243 maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
244244 p.contentViewPort.Height = maxContentHeight
245245 p.contentViewPort.SetContent(formattedDiff)
246−
246+
247247 // Style the viewport
248248 var contentBorder lipgloss.Border
249249 var borderColor lipgloss.TerminalColor
250−
250+
251251 if p.isViewportFocus {
252252 contentBorder = lipgloss.DoubleBorder()
253253 borderColor = styles.Blue
@@ -255,47 +255,47 @@ func (p *permissionDialogCmp) render() string {
255255 contentBorder = lipgloss.RoundedBorder()
256256 borderColor = styles.Flamingo
257257 }
258−
258+
259259 contentStyle := lipgloss.NewStyle().
260260 MarginTop(1).
261261 Padding(0, 1).
262262 Border(contentBorder).
263263 BorderForeground(borderColor)
264−
264+
265265 if p.isViewportFocus {
266266 contentStyle = contentStyle.BorderBackground(styles.Surface0)
267267 }
268−
268+
269269 contentFinal := contentStyle.Render(p.contentViewPort.View())
270−
270+
271271 return lipgloss.JoinVertical(
272272 lipgloss.Top,
273273 headerContent,
274274 contentFinal,
275275 form,
276276 )
277−
277+
278278 case tools.WriteToolName:
279279 pr := p.permission.Params.(tools.WritePermissionsParams)
280280 headerParts = append(headerParts, keyStyle.Render("Content"))
281281 // Recreate header content with the updated headerParts
282282 headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
283−
283+
284284 // Format the diff with colors
285285 formattedDiff := formatDiff(pr.Content)
286−
286+
287287 // Set up viewport for the content
288288 p.contentViewPort.Width = p.width - 2 - 2
289−
289+
290290 // Calculate content height dynamically based on window size
291291 maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
292292 p.contentViewPort.Height = maxContentHeight
293293 p.contentViewPort.SetContent(formattedDiff)
294−
294+
295295 // Style the viewport
296296 var contentBorder lipgloss.Border
297297 var borderColor lipgloss.TerminalColor
298−
298+
299299 if p.isViewportFocus {
300300 contentBorder = lipgloss.DoubleBorder()
301301 borderColor = styles.Blue
@@ -303,75 +303,75 @@ func (p *permissionDialogCmp) render() string {
303303 contentBorder = lipgloss.RoundedBorder()
304304 borderColor = styles.Flamingo
305305 }
306−
306+
307307 contentStyle := lipgloss.NewStyle().
308308 MarginTop(1).
309309 Padding(0, 1).
310310 Border(contentBorder).
311311 BorderForeground(borderColor)
312−
312+
313313 if p.isViewportFocus {
314314 contentStyle = contentStyle.BorderBackground(styles.Surface0)
315315 }
316−
316+
317317 contentFinal := contentStyle.Render(p.contentViewPort.View())
318−
318+
319319 return lipgloss.JoinVertical(
320320 lipgloss.Top,
321321 headerContent,
322322 contentFinal,
323323 form,
324324 )
325−
325+
326326 case tools.FetchToolName:
327327 pr := p.permission.Params.(tools.FetchPermissionsParams)
328328 headerParts = append(headerParts, keyStyle.Render("URL: "+pr.URL))
329329 content := p.permission.Description
330−
330+
331331 renderedContent, _ := r.Render(content)
332332 p.contentViewPort.Width = p.width - 2 - 2
333333 p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
334334 p.contentViewPort.SetContent(renderedContent)
335−
335+
336336 // Style the viewport
337337 contentStyle := lipgloss.NewStyle().
338338 MarginTop(1).
339339 Padding(0, 1).
340340 Border(lipgloss.RoundedBorder()).
341341 BorderForeground(styles.Flamingo)
342−
342+
343343 contentFinal := contentStyle.Render(p.contentViewPort.View())
344344 if renderedContent == "" {
345345 contentFinal = ""
346346 }
347−
347+
348348 return lipgloss.JoinVertical(
349349 lipgloss.Top,
350350 headerContent,
351351 contentFinal,
352352 form,
353353 )
354−
354+
355355 default:
356356 content := p.permission.Description
357−
357+
358358 renderedContent, _ := r.Render(content)
359359 p.contentViewPort.Width = p.width - 2 - 2
360360 p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
361361 p.contentViewPort.SetContent(renderedContent)
362−
362+
363363 // Style the viewport
364364 contentStyle := lipgloss.NewStyle().
365365 MarginTop(1).
366366 Padding(0, 1).
367367 Border(lipgloss.RoundedBorder()).
368368 BorderForeground(styles.Flamingo)
369−
369+
370370 contentFinal := contentStyle.Render(p.contentViewPort.View())
371371 if renderedContent == "" {
372372 contentFinal = ""
373373 }
374−
374+
375375 return lipgloss.JoinVertical(
376376 lipgloss.Top,
377377 headerContent,
internal/tui/components/repl/editor.go+1−1
@@ -140,7 +140,7 @@ func (m *editorCmp) Send() tea.Cmd {
140140 return func() tea.Msg {
141141 messages, _ := m.app.Messages.List(m.sessionID)
142142 if hasUnfinishedMessages(messages) {
143− return util.InfoMsg("Assistant is still working on the previous message")
143+ return util.ReportWarn("Assistant is still working on the previous message")
144144 }
145145 a, _ := agent.NewCoderAgent(m.app)
146146
internal/tui/tui.go+4−4
@@ -77,6 +77,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
7777 switch msg := msg.(type) {
7878 case pubsub.Event[permission.PermissionRequest]:
7979 return a, dialog.NewPermissionDialogCmd(msg.Payload)
80+ case pubsub.Event[util.InfoMsg]:
81+ a.status, _ = a.status.Update(msg)
8082 case dialog.PermissionResponseMsg:
8183 switch msg.Action {
8284 case dialog.PermissionAllow:
@@ -121,8 +123,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
121123 return a, a.moveToPage(msg.ID)
122124 case util.InfoMsg:
123125 a.status, _ = a.status.Update(msg)
124− case util.ErrorMsg:
125− a.status, _ = a.status.Update(msg)
126126 case tea.KeyMsg:
127127 if a.editorMode == vimtea.ModeNormal {
128128 switch {
@@ -141,7 +141,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
141141 if a.currentPage == page.ReplPage {
142142 sessions, err := a.app.Sessions.List()
143143 if err != nil {
144− return a, util.CmdHandler(util.ErrorMsg(err))
144+ return a, util.CmdHandler(util.ReportError(err))
145145 }
146146 lastSession := sessions[0]
147147 if lastSession.MessageCount == 0 {
@@ -149,7 +149,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
149149 }
150150 s, err := a.app.Sessions.Create("New Session")
151151 if err != nil {
152− return a, util.CmdHandler(util.ErrorMsg(err))
152+ return a, util.CmdHandler(util.ReportError(err))
153153 }
154154 return a, util.CmdHandler(repl.SelectedSessionMsg{SessionID: s.ID})
155155 }
internal/tui/util/util.go+30−3
@@ -9,12 +9,39 @@ func CmdHandler(msg tea.Msg) tea.Cmd {
99}
1010
1111func ReportError(err error) tea.Cmd {
12− return CmdHandler(ErrorMsg(err))
12+ return CmdHandler(InfoMsg{
13+ Type: InfoTypeError,
14+ Msg: err.Error(),
15+ })
16+}
17+
18+type InfoType int
19+
20+const (
21+ InfoTypeInfo InfoType = iota
22+ InfoTypeWarn
23+ InfoTypeError
24+)
25+
26+func ReportInfo(info string) tea.Cmd {
27+ return CmdHandler(InfoMsg{
28+ Type: InfoTypeInfo,
29+ Msg: info,
30+ })
31+}
32+
33+func ReportWarn(warn string) tea.Cmd {
34+ return CmdHandler(InfoMsg{
35+ Type: InfoTypeWarn,
36+ Msg: warn,
37+ })
1338}
1439
1540type (
16− InfoMsg string
17− ErrorMsg error
41+ InfoMsg struct {
42+ Type InfoType
43+ Msg string
44+ }
1845 ClearStatusMsg struct{}
1946)
2047