on main help sign in
Merge pull request #22 from adamdottv/adam/retries
fde04bbKujtim Hoxha
cmd/root.go+10
@@ -107,6 +107,16 @@ func setupSubscriptions(app *app.App) (chan tea.Msg, func()) {
107 wg.Done()
107 wg.Done()
108 }()
108 }()
109 }
109 }
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+ }
110 return ch, func() {
120 return ch, func() {
111 cancel()
121 cancel()
112 wg.Wait()
122 wg.Wait()
internal/app/services.go+4
@@ -11,7 +11,9 @@ import (
11 "github.com/kujtimiihoxha/termai/internal/lsp/watcher"
11 "github.com/kujtimiihoxha/termai/internal/lsp/watcher"
12 "github.com/kujtimiihoxha/termai/internal/message"
12 "github.com/kujtimiihoxha/termai/internal/message"
13 "github.com/kujtimiihoxha/termai/internal/permission"
13 "github.com/kujtimiihoxha/termai/internal/permission"
14+ "github.com/kujtimiihoxha/termai/internal/pubsub"
14 "github.com/kujtimiihoxha/termai/internal/session"
15 "github.com/kujtimiihoxha/termai/internal/session"
16+ "github.com/kujtimiihoxha/termai/internal/tui/util"
15)
17)
16
18
17type App struct {
19type App struct {
@@ -25,6 +27,7 @@ type App struct {
25
27
26 Logger logging.Interface
28 Logger logging.Interface
27
29
30+ Status *pubsub.Broker[util.InfoMsg]
28 ceanups []func()
31 ceanups []func()
29}
32}
30
33
@@ -43,6 +46,7 @@ func New(ctx context.Context, conn *sql.DB) *App {
43 Messages: messages,
46 Messages: messages,
44 Permissions: permission.NewPermissionService(),
47 Permissions: permission.NewPermissionService(),
45 Logger: log,
48 Logger: log,
49+ Status: pubsub.NewBroker[util.InfoMsg](),
46 LSPClients: make(map[string]*lsp.Client),
50 LSPClients: make(map[string]*lsp.Client),
47 }
51 }
48
52
internal/llm/agent/agent.go+18−1
@@ -15,6 +15,8 @@ import (
15 "github.com/kujtimiihoxha/termai/internal/llm/provider"
15 "github.com/kujtimiihoxha/termai/internal/llm/provider"
16 "github.com/kujtimiihoxha/termai/internal/llm/tools"
16 "github.com/kujtimiihoxha/termai/internal/llm/tools"
17 "github.com/kujtimiihoxha/termai/internal/message"
17 "github.com/kujtimiihoxha/termai/internal/message"
18+ "github.com/kujtimiihoxha/termai/internal/pubsub"
19+ "github.com/kujtimiihoxha/termai/internal/tui/util"
18)
20)
19
21
20type Agent interface {
22type Agent interface {
@@ -92,9 +94,24 @@ func (c *agent) processEvent(
92 assistantMsg.AppendContent(event.Content)
94 assistantMsg.AppendContent(event.Content)
93 return c.Messages.Update(*assistantMsg)
95 return c.Messages.Update(*assistantMsg)
94 case provider.EventError:
96 case provider.EventError:
97+ // TODO: remove when realease
95 log.Println("error", event.Error)
98 log.Println("error", event.Error)
99+ c.App.Status.Publish(pubsub.UpdatedEvent, util.InfoMsg{
100+ Type: util.InfoTypeError,
101+ Msg: event.Error.Error(),
102+ })
96 return event.Error
103 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+ })
98 case provider.EventComplete:
115 case provider.EventComplete:
99 assistantMsg.SetToolCalls(event.Response.ToolCalls)
116 assistantMsg.SetToolCalls(event.Response.ToolCalls)
100 assistantMsg.AddFinish(event.Response.FinishReason)
117 assistantMsg.AddFinish(event.Response.FinishReason)
internal/llm/provider/anthropic.go+164−70
@@ -4,7 +4,9 @@ import (
4 "context"
4 "context"
5 "encoding/json"
5 "encoding/json"
6 "errors"
6 "errors"
7+ "fmt"
7 "strings"
8 "strings"
9+ "time"
8
10
9 "github.com/anthropics/anthropic-sdk-go"
11 "github.com/anthropics/anthropic-sdk-go"
10 "github.com/anthropics/anthropic-sdk-go/option"
12 "github.com/anthropics/anthropic-sdk-go/option"
@@ -68,21 +70,24 @@ func (a *anthropicProvider) SendMessages(ctx context.Context, messages []message
68 anthropicMessages := a.convertToAnthropicMessages(messages)
70 anthropicMessages := a.convertToAnthropicMessages(messages)
69 anthropicTools := a.convertToAnthropicTools(tools)
71 anthropicTools := a.convertToAnthropicTools(tools)
70
72
71− response, err := a.client.Messages.New(ctx, anthropic.MessageNewParams{
73+ response, err := a.client.Messages.New(
74+ ctx,
75+ anthropic.MessageNewParams{
72− Model: anthropic.Model(a.model.APIModel),
76+ Model: anthropic.Model(a.model.APIModel),
73− MaxTokens: a.maxTokens,
77+ MaxTokens: a.maxTokens,
74− Temperature: anthropic.Float(0),
78+ Temperature: anthropic.Float(0),
75− Messages: anthropicMessages,
79+ Messages: anthropicMessages,
76− Tools: anthropicTools,
80+ Tools: anthropicTools,
77− System: []anthropic.TextBlockParam{
81+ System: []anthropic.TextBlockParam{
78− {
82+ {
79− Text: a.systemMessage,
83+ Text: a.systemMessage,
80− CacheControl: anthropic.CacheControlEphemeralParam{
84+ CacheControl: anthropic.CacheControlEphemeralParam{
81− Type: "ephemeral",
85+ Type: "ephemeral",
86+ },
82 },
87 },
83 },
88 },
84 },
89 },
85− })
90+ )
86 if err != nil {
91 if err != nil {
87 return nil, err
92 return nil, err
88 }
93 }
@@ -121,83 +126,171 @@ func (a *anthropicProvider) StreamResponse(ctx context.Context, messages []messa
121 temperature = anthropic.Float(1)
126 temperature = anthropic.Float(1)
122 }
127 }
123
128
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−
141 eventChan := make(chan ProviderEvent)
129 eventChan := make(chan ProviderEvent)
142
130
143 go func() {
131 go func() {
144 defer close(eventChan)
132 defer close(eventChan)
145
133
146− accumulatedMessage := anthropic.Message{}
134+ const maxRetries = 8
135+ attempts := 0
147
136
148− for stream.Next() {
137+ for {
149− event := stream.Current()
138+ // If this isn't the first attempt, we're retrying
150− err := accumulatedMessage.Accumulate(event)
139+ if attempts > 0 {
151− if err != nil {
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():
152− eventChan <- ProviderEvent{Type: EventError, Error: err}
162+ eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
153− return
163+ return
164+ case <-time.After(time.Duration(totalBackoffMs) * time.Millisecond):
165+ // Continue with retry
166+ }
154 }
167 }
155
168
156− switch event := event.AsAny().(type) {
169+ attempts++
157− case anthropic.ContentBlockStartEvent:
170+
158− eventChan <- ProviderEvent{Type: EventContentStart}
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+ )
159
191
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+
160− case anthropic.ContentBlockDeltaEvent:
209+ case anthropic.ContentBlockDeltaEvent:
161− if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" {
210+ if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" {
162− eventChan <- ProviderEvent{
211+ eventChan <- ProviderEvent{
163− Type: EventThinkingDelta,
212+ Type: EventThinkingDelta,
164− Thinking: event.Delta.Thinking,
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+ }
165 }
220 }
166− } else if event.Delta.Type == "text_delta" && event.Delta.Text != "" {
221+
222+ case anthropic.ContentBlockStopEvent:
167− eventChan <- ProviderEvent{
223+ eventChan <- ProviderEvent{Type: EventContentStop}
168− Type: EventContentDelta,
224+
169− Content: event.Delta.Text,
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+ }
170 }
232 }
171− }
172
233
173− case anthropic.ContentBlockStopEvent:
234+ toolCalls := a.extractToolCalls(accumulatedMessage.Content)
174− eventChan <- ProviderEvent{Type: EventContentStop}
235+ tokenUsage := a.extractTokenUsage(accumulatedMessage.Usage)
175
236
176− case anthropic.MessageStopEvent:
237+ eventChan <- ProviderEvent{
177− content := ""
238+ Type: EventComplete,
178− for _, block := range accumulatedMessage.Content {
239+ Response: &ProviderResponse{
179− if text, ok := block.AsAny().(anthropic.TextBlock); ok {
240+ Content: content,
180− content += text.Text
241+ ToolCalls: toolCalls,
242+ Usage: tokenUsage,
243+ FinishReason: string(accumulatedMessage.StopReason),
244+ },
181 }
245 }
182 }
246 }
247+ }
183
248
184− toolCalls := a.extractToolCalls(accumulatedMessage.Content)
249+ // If the stream completed successfully, we're done
185− tokenUsage := a.extractTokenUsage(accumulatedMessage.Usage)
250+ if streamSuccess {
251+ return
252+ }
186
253
187− eventChan <- ProviderEvent{
254+ // Check for stream errors
188− Type: EventComplete,
255+ err := stream.Err()
189− Response: &ProviderResponse{
256+ if err != nil {
190− Content: content,
257+ var apierr *anthropic.Error
191− ToolCalls: toolCalls,
258+ if errors.As(err, &apierr) {
192− Usage: tokenUsage,
259+ if apierr.StatusCode == 429 || apierr.StatusCode == 529 {
193− FinishReason: string(accumulatedMessage.StopReason),
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
194− },
287+ }
195 }
288 }
196− }
197− }
198
289
199− if stream.Err() != nil {
290+ // For non-rate limit errors, report and exit
200− eventChan <- ProviderEvent{Type: EventError, Error: stream.Err()}
291+ eventChan <- ProviderEvent{Type: EventError, Error: err}
292+ return
293+ }
201 }
294 }
202 }()
295 }()
203
296
@@ -311,3 +404,4 @@ func (a *anthropicProvider) convertToAnthropicMessages(messages []message.Messag
311
404
312 return anthropicMessages
405 return anthropicMessages
313}
406}
407+
internal/llm/provider/provider.go+5
@@ -17,6 +17,8 @@ const (
17 EventContentStop EventType = "content_stop"
17 EventContentStop EventType = "content_stop"
18 EventComplete EventType = "complete"
18 EventComplete EventType = "complete"
19 EventError EventType = "error"
19 EventError EventType = "error"
20+ EventWarning EventType = "warning"
21+ EventInfo EventType = "info"
20)
22)
21
23
22type TokenUsage struct {
24type TokenUsage struct {
@@ -40,6 +42,9 @@ type ProviderEvent struct {
40 ToolCall *message.ToolCall
42 ToolCall *message.ToolCall
41 Error error
43 Error error
42 Response *ProviderResponse
44 Response *ProviderResponse
45+
46+ // Used for giving users info on e.x retry
47+ Info string
43}
48}
44
49
45type Provider interface {
50type Provider interface {
internal/tui/components/core/status.go+23−25
@@ -7,16 +7,16 @@ import (
7 "github.com/charmbracelet/lipgloss"
7 "github.com/charmbracelet/lipgloss"
8 "github.com/kujtimiihoxha/termai/internal/config"
8 "github.com/kujtimiihoxha/termai/internal/config"
9 "github.com/kujtimiihoxha/termai/internal/llm/models"
9 "github.com/kujtimiihoxha/termai/internal/llm/models"
10+ "github.com/kujtimiihoxha/termai/internal/pubsub"
10 "github.com/kujtimiihoxha/termai/internal/tui/styles"
11 "github.com/kujtimiihoxha/termai/internal/tui/styles"
11 "github.com/kujtimiihoxha/termai/internal/tui/util"
12 "github.com/kujtimiihoxha/termai/internal/tui/util"
12 "github.com/kujtimiihoxha/termai/internal/version"
13 "github.com/kujtimiihoxha/termai/internal/version"
13)
14)
14
15
15type statusCmp struct {
16type statusCmp struct {
16− err error
17+ info *util.InfoMsg
17− info string
18− width int
18+ width int
19− messageTTL time.Duration
19+ messageTTL time.Duration
20}
20}
21
21
22// clearMessageCmd is a command that clears status messages after a timeout
22// 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) {
34 switch msg := msg.(type) {
34 switch msg := msg.(type) {
35 case tea.WindowSizeMsg:
35 case tea.WindowSizeMsg:
36 m.width = msg.Width
36 m.width = msg.Width
37− case util.ErrorMsg:
37+ return m, m.clearMessageCmd()
38− m.err = msg
38+ case pubsub.Event[util.InfoMsg]:
39− m.info = ""
39+ m.info = &msg.Payload
40 return m, m.clearMessageCmd()
40 return m, m.clearMessageCmd()
41 case util.InfoMsg:
41 case util.InfoMsg:
42− m.info = string(msg)
42+ m.info = &msg
43− m.err = nil
44 return m, m.clearMessageCmd()
43 return m, m.clearMessageCmd()
45 case util.ClearStatusMsg:
44 case util.ClearStatusMsg:
46− m.info = ""
45+ m.info = nil
47− m.err = nil
48 }
46 }
49 return m, nil
47 return m, nil
50}
48}
@@ -56,25 +54,25 @@ var (
56
54
57func (m statusCmp) View() string {
55func (m statusCmp) View() string {
58 status := styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render("? help")
56 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 != "" {
57+ if m.info != nil {
67− status += styles.Padded.
58+ infoStyle := styles.Padded.
68 Foreground(styles.Base).
59 Foreground(styles.Base).
69− Background(styles.Green).
70− Width(m.availableFooterMsgWidth()).
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+ }
71− Render(m.info)
69+ status += infoStyle.Render(m.info.Msg)
72 } else {
70 } else {
73 status += styles.Padded.
71 status += styles.Padded.
74 Foreground(styles.Base).
72 Foreground(styles.Base).
75 Background(styles.LightGrey).
73 Background(styles.LightGrey).
76 Width(m.availableFooterMsgWidth()).
74 Width(m.availableFooterMsgWidth()).
77− Render(m.info)
75+ Render("")
78 }
76 }
79 status += m.model()
77 status += m.model()
80 status += versionWidget
78 status += versionWidget
@@ -93,6 +91,6 @@ func (m statusCmp) model() string {
93
91
94func NewStatusCmp() tea.Model {
92func NewStatusCmp() tea.Model {
95 return &statusCmp{
93 return &statusCmp{
96− messageTTL: 5 * time.Second,
94+ messageTTL: 15 * time.Second,
97 }
95 }
98}
96}
internal/tui/components/dialog/permission.go+48−48
@@ -69,13 +69,13 @@ type permissionDialogCmp struct {
69func formatDiff(diffText string) string {
69func formatDiff(diffText string) string {
70 lines := strings.Split(diffText, "\n")
70 lines := strings.Split(diffText, "\n")
71 var formattedLines []string
71 var formattedLines []string
72−
72+
73 // Define styles for different line types
73 // Define styles for different line types
74 addStyle := lipgloss.NewStyle().Foreground(styles.Green)
74 addStyle := lipgloss.NewStyle().Foreground(styles.Green)
75 removeStyle := lipgloss.NewStyle().Foreground(styles.Red)
75 removeStyle := lipgloss.NewStyle().Foreground(styles.Red)
76 headerStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Blue)
76 headerStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Blue)
77 contextStyle := lipgloss.NewStyle().Foreground(styles.SubText0)
77 contextStyle := lipgloss.NewStyle().Foreground(styles.SubText0)
78−
78+
79 // Process each line
79 // Process each line
80 for _, line := range lines {
80 for _, line := range lines {
81 if strings.HasPrefix(line, "+") {
81 if strings.HasPrefix(line, "+") {
@@ -90,7 +90,7 @@ func formatDiff(diffText string) string {
90 formattedLines = append(formattedLines, line)
90 formattedLines = append(formattedLines, line)
91 }
91 }
92 }
92 }
93−
93+
94 // Join all formatted lines
94 // Join all formatted lines
95 return strings.Join(formattedLines, "\n")
95 return strings.Join(formattedLines, "\n")
96}
96}
@@ -112,13 +112,13 @@ func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
112 p.selectOption.Blur()
112 p.selectOption.Blur()
113 // Add a visual indicator for focus change
113 // Add a visual indicator for focus change
114 cmds = append(cmds, tea.Batch(
114 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"),
116 ))
116 ))
117 } else {
117 } else {
118 p.selectOption.Focus()
118 p.selectOption.Focus()
119 // Add a visual indicator for focus change
119 // Add a visual indicator for focus change
120 cmds = append(cmds, tea.Batch(
120 cmds = append(cmds, tea.Batch(
121− util.CmdHandler(util.InfoMsg("Select an action")),
121+ util.CmdHandler(util.ReportInfo("Select an action")),
122 ))
122 ))
123 }
123 }
124 return p, tea.Batch(cmds...)
124 return p, tea.Batch(cmds...)
@@ -162,44 +162,44 @@ func (p *permissionDialogCmp) render() string {
162 lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Path:"), " ", valueStyle.Render(p.permission.Path)),
162 lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Path:"), " ", valueStyle.Render(p.permission.Path)),
163 " ",
163 " ",
164 }
164 }
165−
165+
166 // Create the header content first so it can be used in all cases
166 // Create the header content first so it can be used in all cases
167 headerContent := lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
167 headerContent := lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
168−
168+
169 r, _ := glamour.NewTermRenderer(
169 r, _ := glamour.NewTermRenderer(
170 glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
170 glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
171 glamour.WithWordWrap(p.width-10),
171 glamour.WithWordWrap(p.width-10),
172 glamour.WithEmoji(),
172 glamour.WithEmoji(),
173 )
173 )
174−
174+
175 // Handle different tool types
175 // Handle different tool types
176 switch p.permission.ToolName {
176 switch p.permission.ToolName {
177 case tools.BashToolName:
177 case tools.BashToolName:
178 pr := p.permission.Params.(tools.BashPermissionsParams)
178 pr := p.permission.Params.(tools.BashPermissionsParams)
179 headerParts = append(headerParts, keyStyle.Render("Command:"))
179 headerParts = append(headerParts, keyStyle.Render("Command:"))
180 content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
180 content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
181−
181+
182 renderedContent, _ := r.Render(content)
182 renderedContent, _ := r.Render(content)
183 p.contentViewPort.Width = p.width - 2 - 2
183 p.contentViewPort.Width = p.width - 2 - 2
184−
184+
185 // Calculate content height dynamically based on content
185 // Calculate content height dynamically based on content
186 contentLines := len(strings.Split(renderedContent, "\n"))
186 contentLines := len(strings.Split(renderedContent, "\n"))
187 // Set a reasonable min/max for the viewport height
187 // Set a reasonable min/max for the viewport height
188 minContentHeight := 3
188 minContentHeight := 3
189 maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
189 maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
190−
190+
191 // Add some padding to the content lines
191 // Add some padding to the content lines
192 contentHeight := contentLines + 2
192 contentHeight := contentLines + 2
193 contentHeight = max(contentHeight, minContentHeight)
193 contentHeight = max(contentHeight, minContentHeight)
194 contentHeight = min(contentHeight, maxContentHeight)
194 contentHeight = min(contentHeight, maxContentHeight)
195 p.contentViewPort.Height = contentHeight
195 p.contentViewPort.Height = contentHeight
196−
196+
197 p.contentViewPort.SetContent(renderedContent)
197 p.contentViewPort.SetContent(renderedContent)
198−
198+
199 // Style the viewport
199 // Style the viewport
200 var contentBorder lipgloss.Border
200 var contentBorder lipgloss.Border
201 var borderColor lipgloss.TerminalColor
201 var borderColor lipgloss.TerminalColor
202−
202+
203 if p.isViewportFocus {
203 if p.isViewportFocus {
204 contentBorder = lipgloss.DoubleBorder()
204 contentBorder = lipgloss.DoubleBorder()
205 borderColor = styles.Blue
205 borderColor = styles.Blue
@@ -207,47 +207,47 @@ func (p *permissionDialogCmp) render() string {
207 contentBorder = lipgloss.RoundedBorder()
207 contentBorder = lipgloss.RoundedBorder()
208 borderColor = styles.Flamingo
208 borderColor = styles.Flamingo
209 }
209 }
210−
210+
211 contentStyle := lipgloss.NewStyle().
211 contentStyle := lipgloss.NewStyle().
212 MarginTop(1).
212 MarginTop(1).
213 Padding(0, 1).
213 Padding(0, 1).
214 Border(contentBorder).
214 Border(contentBorder).
215 BorderForeground(borderColor)
215 BorderForeground(borderColor)
216−
216+
217 if p.isViewportFocus {
217 if p.isViewportFocus {
218 contentStyle = contentStyle.BorderBackground(styles.Surface0)
218 contentStyle = contentStyle.BorderBackground(styles.Surface0)
219 }
219 }
220−
220+
221 contentFinal := contentStyle.Render(p.contentViewPort.View())
221 contentFinal := contentStyle.Render(p.contentViewPort.View())
222−
222+
223 return lipgloss.JoinVertical(
223 return lipgloss.JoinVertical(
224 lipgloss.Top,
224 lipgloss.Top,
225 headerContent,
225 headerContent,
226 contentFinal,
226 contentFinal,
227 form,
227 form,
228 )
228 )
229−
229+
230 case tools.EditToolName:
230 case tools.EditToolName:
231 pr := p.permission.Params.(tools.EditPermissionsParams)
231 pr := p.permission.Params.(tools.EditPermissionsParams)
232 headerParts = append(headerParts, keyStyle.Render("Update"))
232 headerParts = append(headerParts, keyStyle.Render("Update"))
233 // Recreate header content with the updated headerParts
233 // Recreate header content with the updated headerParts
234 headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
234 headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
235−
235+
236 // Format the diff with colors
236 // Format the diff with colors
237 formattedDiff := formatDiff(pr.Diff)
237 formattedDiff := formatDiff(pr.Diff)
238−
238+
239 // Set up viewport for the diff content
239 // Set up viewport for the diff content
240 p.contentViewPort.Width = p.width - 2 - 2
240 p.contentViewPort.Width = p.width - 2 - 2
241−
241+
242 // Calculate content height dynamically based on window size
242 // Calculate content height dynamically based on window size
243 maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
243 maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
244 p.contentViewPort.Height = maxContentHeight
244 p.contentViewPort.Height = maxContentHeight
245 p.contentViewPort.SetContent(formattedDiff)
245 p.contentViewPort.SetContent(formattedDiff)
246−
246+
247 // Style the viewport
247 // Style the viewport
248 var contentBorder lipgloss.Border
248 var contentBorder lipgloss.Border
249 var borderColor lipgloss.TerminalColor
249 var borderColor lipgloss.TerminalColor
250−
250+
251 if p.isViewportFocus {
251 if p.isViewportFocus {
252 contentBorder = lipgloss.DoubleBorder()
252 contentBorder = lipgloss.DoubleBorder()
253 borderColor = styles.Blue
253 borderColor = styles.Blue
@@ -255,47 +255,47 @@ func (p *permissionDialogCmp) render() string {
255 contentBorder = lipgloss.RoundedBorder()
255 contentBorder = lipgloss.RoundedBorder()
256 borderColor = styles.Flamingo
256 borderColor = styles.Flamingo
257 }
257 }
258−
258+
259 contentStyle := lipgloss.NewStyle().
259 contentStyle := lipgloss.NewStyle().
260 MarginTop(1).
260 MarginTop(1).
261 Padding(0, 1).
261 Padding(0, 1).
262 Border(contentBorder).
262 Border(contentBorder).
263 BorderForeground(borderColor)
263 BorderForeground(borderColor)
264−
264+
265 if p.isViewportFocus {
265 if p.isViewportFocus {
266 contentStyle = contentStyle.BorderBackground(styles.Surface0)
266 contentStyle = contentStyle.BorderBackground(styles.Surface0)
267 }
267 }
268−
268+
269 contentFinal := contentStyle.Render(p.contentViewPort.View())
269 contentFinal := contentStyle.Render(p.contentViewPort.View())
270−
270+
271 return lipgloss.JoinVertical(
271 return lipgloss.JoinVertical(
272 lipgloss.Top,
272 lipgloss.Top,
273 headerContent,
273 headerContent,
274 contentFinal,
274 contentFinal,
275 form,
275 form,
276 )
276 )
277−
277+
278 case tools.WriteToolName:
278 case tools.WriteToolName:
279 pr := p.permission.Params.(tools.WritePermissionsParams)
279 pr := p.permission.Params.(tools.WritePermissionsParams)
280 headerParts = append(headerParts, keyStyle.Render("Content"))
280 headerParts = append(headerParts, keyStyle.Render("Content"))
281 // Recreate header content with the updated headerParts
281 // Recreate header content with the updated headerParts
282 headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
282 headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
283−
283+
284 // Format the diff with colors
284 // Format the diff with colors
285 formattedDiff := formatDiff(pr.Content)
285 formattedDiff := formatDiff(pr.Content)
286−
286+
287 // Set up viewport for the content
287 // Set up viewport for the content
288 p.contentViewPort.Width = p.width - 2 - 2
288 p.contentViewPort.Width = p.width - 2 - 2
289−
289+
290 // Calculate content height dynamically based on window size
290 // Calculate content height dynamically based on window size
291 maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
291 maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
292 p.contentViewPort.Height = maxContentHeight
292 p.contentViewPort.Height = maxContentHeight
293 p.contentViewPort.SetContent(formattedDiff)
293 p.contentViewPort.SetContent(formattedDiff)
294−
294+
295 // Style the viewport
295 // Style the viewport
296 var contentBorder lipgloss.Border
296 var contentBorder lipgloss.Border
297 var borderColor lipgloss.TerminalColor
297 var borderColor lipgloss.TerminalColor
298−
298+
299 if p.isViewportFocus {
299 if p.isViewportFocus {
300 contentBorder = lipgloss.DoubleBorder()
300 contentBorder = lipgloss.DoubleBorder()
301 borderColor = styles.Blue
301 borderColor = styles.Blue
@@ -303,75 +303,75 @@ func (p *permissionDialogCmp) render() string {
303 contentBorder = lipgloss.RoundedBorder()
303 contentBorder = lipgloss.RoundedBorder()
304 borderColor = styles.Flamingo
304 borderColor = styles.Flamingo
305 }
305 }
306−
306+
307 contentStyle := lipgloss.NewStyle().
307 contentStyle := lipgloss.NewStyle().
308 MarginTop(1).
308 MarginTop(1).
309 Padding(0, 1).
309 Padding(0, 1).
310 Border(contentBorder).
310 Border(contentBorder).
311 BorderForeground(borderColor)
311 BorderForeground(borderColor)
312−
312+
313 if p.isViewportFocus {
313 if p.isViewportFocus {
314 contentStyle = contentStyle.BorderBackground(styles.Surface0)
314 contentStyle = contentStyle.BorderBackground(styles.Surface0)
315 }
315 }
316−
316+
317 contentFinal := contentStyle.Render(p.contentViewPort.View())
317 contentFinal := contentStyle.Render(p.contentViewPort.View())
318−
318+
319 return lipgloss.JoinVertical(
319 return lipgloss.JoinVertical(
320 lipgloss.Top,
320 lipgloss.Top,
321 headerContent,
321 headerContent,
322 contentFinal,
322 contentFinal,
323 form,
323 form,
324 )
324 )
325−
325+
326 case tools.FetchToolName:
326 case tools.FetchToolName:
327 pr := p.permission.Params.(tools.FetchPermissionsParams)
327 pr := p.permission.Params.(tools.FetchPermissionsParams)
328 headerParts = append(headerParts, keyStyle.Render("URL: "+pr.URL))
328 headerParts = append(headerParts, keyStyle.Render("URL: "+pr.URL))
329 content := p.permission.Description
329 content := p.permission.Description
330−
330+
331 renderedContent, _ := r.Render(content)
331 renderedContent, _ := r.Render(content)
332 p.contentViewPort.Width = p.width - 2 - 2
332 p.contentViewPort.Width = p.width - 2 - 2
333 p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
333 p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
334 p.contentViewPort.SetContent(renderedContent)
334 p.contentViewPort.SetContent(renderedContent)
335−
335+
336 // Style the viewport
336 // Style the viewport
337 contentStyle := lipgloss.NewStyle().
337 contentStyle := lipgloss.NewStyle().
338 MarginTop(1).
338 MarginTop(1).
339 Padding(0, 1).
339 Padding(0, 1).
340 Border(lipgloss.RoundedBorder()).
340 Border(lipgloss.RoundedBorder()).
341 BorderForeground(styles.Flamingo)
341 BorderForeground(styles.Flamingo)
342−
342+
343 contentFinal := contentStyle.Render(p.contentViewPort.View())
343 contentFinal := contentStyle.Render(p.contentViewPort.View())
344 if renderedContent == "" {
344 if renderedContent == "" {
345 contentFinal = ""
345 contentFinal = ""
346 }
346 }
347−
347+
348 return lipgloss.JoinVertical(
348 return lipgloss.JoinVertical(
349 lipgloss.Top,
349 lipgloss.Top,
350 headerContent,
350 headerContent,
351 contentFinal,
351 contentFinal,
352 form,
352 form,
353 )
353 )
354−
354+
355 default:
355 default:
356 content := p.permission.Description
356 content := p.permission.Description
357−
357+
358 renderedContent, _ := r.Render(content)
358 renderedContent, _ := r.Render(content)
359 p.contentViewPort.Width = p.width - 2 - 2
359 p.contentViewPort.Width = p.width - 2 - 2
360 p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
360 p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
361 p.contentViewPort.SetContent(renderedContent)
361 p.contentViewPort.SetContent(renderedContent)
362−
362+
363 // Style the viewport
363 // Style the viewport
364 contentStyle := lipgloss.NewStyle().
364 contentStyle := lipgloss.NewStyle().
365 MarginTop(1).
365 MarginTop(1).
366 Padding(0, 1).
366 Padding(0, 1).
367 Border(lipgloss.RoundedBorder()).
367 Border(lipgloss.RoundedBorder()).
368 BorderForeground(styles.Flamingo)
368 BorderForeground(styles.Flamingo)
369−
369+
370 contentFinal := contentStyle.Render(p.contentViewPort.View())
370 contentFinal := contentStyle.Render(p.contentViewPort.View())
371 if renderedContent == "" {
371 if renderedContent == "" {
372 contentFinal = ""
372 contentFinal = ""
373 }
373 }
374−
374+
375 return lipgloss.JoinVertical(
375 return lipgloss.JoinVertical(
376 lipgloss.Top,
376 lipgloss.Top,
377 headerContent,
377 headerContent,
internal/tui/components/repl/editor.go+1−1
@@ -140,7 +140,7 @@ func (m *editorCmp) Send() tea.Cmd {
140 return func() tea.Msg {
140 return func() tea.Msg {
141 messages, _ := m.app.Messages.List(m.sessionID)
141 messages, _ := m.app.Messages.List(m.sessionID)
142 if hasUnfinishedMessages(messages) {
142 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")
144 }
144 }
145 a, _ := agent.NewCoderAgent(m.app)
145 a, _ := agent.NewCoderAgent(m.app)
146
146
internal/tui/tui.go+4−4
@@ -77,6 +77,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
77 switch msg := msg.(type) {
77 switch msg := msg.(type) {
78 case pubsub.Event[permission.PermissionRequest]:
78 case pubsub.Event[permission.PermissionRequest]:
79 return a, dialog.NewPermissionDialogCmd(msg.Payload)
79 return a, dialog.NewPermissionDialogCmd(msg.Payload)
80+ case pubsub.Event[util.InfoMsg]:
81+ a.status, _ = a.status.Update(msg)
80 case dialog.PermissionResponseMsg:
82 case dialog.PermissionResponseMsg:
81 switch msg.Action {
83 switch msg.Action {
82 case dialog.PermissionAllow:
84 case dialog.PermissionAllow:
@@ -121,8 +123,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
121 return a, a.moveToPage(msg.ID)
123 return a, a.moveToPage(msg.ID)
122 case util.InfoMsg:
124 case util.InfoMsg:
123 a.status, _ = a.status.Update(msg)
125 a.status, _ = a.status.Update(msg)
124− case util.ErrorMsg:
125− a.status, _ = a.status.Update(msg)
126 case tea.KeyMsg:
126 case tea.KeyMsg:
127 if a.editorMode == vimtea.ModeNormal {
127 if a.editorMode == vimtea.ModeNormal {
128 switch {
128 switch {
@@ -141,7 +141,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
141 if a.currentPage == page.ReplPage {
141 if a.currentPage == page.ReplPage {
142 sessions, err := a.app.Sessions.List()
142 sessions, err := a.app.Sessions.List()
143 if err != nil {
143 if err != nil {
144− return a, util.CmdHandler(util.ErrorMsg(err))
144+ return a, util.CmdHandler(util.ReportError(err))
145 }
145 }
146 lastSession := sessions[0]
146 lastSession := sessions[0]
147 if lastSession.MessageCount == 0 {
147 if lastSession.MessageCount == 0 {
@@ -149,7 +149,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
149 }
149 }
150 s, err := a.app.Sessions.Create("New Session")
150 s, err := a.app.Sessions.Create("New Session")
151 if err != nil {
151 if err != nil {
152− return a, util.CmdHandler(util.ErrorMsg(err))
152+ return a, util.CmdHandler(util.ReportError(err))
153 }
153 }
154 return a, util.CmdHandler(repl.SelectedSessionMsg{SessionID: s.ID})
154 return a, util.CmdHandler(repl.SelectedSessionMsg{SessionID: s.ID})
155 }
155 }
internal/tui/util/util.go+30−3
@@ -9,12 +9,39 @@ func CmdHandler(msg tea.Msg) tea.Cmd {
9}
9}
10
10
11func ReportError(err error) tea.Cmd {
11func 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+ })
13}
38}
14
39
15type (
40type (
41+ InfoMsg struct {
42+ Type InfoType
16− InfoMsg string
43+ Msg string
17− ErrorMsg error
44+ }
18 ClearStatusMsg struct{}
45 ClearStatusMsg struct{}
19)
46)
20
47