diff --git a/cmd/root.go b/cmd/root.go index c4e99985..c02d9423 100644 --- a/cmd/root.go +++ b/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() diff --git a/internal/app/services.go b/internal/app/services.go index 668da9a1..52ea5127 100644 --- a/internal/app/services.go +++ b/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), } diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index cb123e78..baf78be6 100644 --- a/internal/llm/agent/agent.go +++ b/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) diff --git a/internal/llm/provider/anthropic.go b/internal/llm/provider/anthropic.go index 9e3775d3..02bd572f 100644 --- a/internal/llm/provider/anthropic.go +++ b/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,84 +126,172 @@ 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} + 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 + } + } + + 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", + }, + }, + }, + }, + ) + + // 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, + } + } + + 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 + } + } + + toolCalls := a.extractToolCalls(accumulatedMessage.Content) + tokenUsage := a.extractTokenUsage(accumulatedMessage.Usage) + + eventChan <- ProviderEvent{ + Type: EventComplete, + Response: &ProviderResponse{ + Content: content, + ToolCalls: toolCalls, + Usage: tokenUsage, + FinishReason: string(accumulatedMessage.StopReason), + }, + } + } + } + + // If the stream completed successfully, we're done + if streamSuccess { return } - switch event := event.AsAny().(type) { - case anthropic.ContentBlockStartEvent: - eventChan <- ProviderEvent{Type: EventContentStart} + // 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 - 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, + // 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 } } - case anthropic.ContentBlockStopEvent: - eventChan <- ProviderEvent{Type: EventContentStop} - - case anthropic.MessageStopEvent: - content := "" - for _, block := range accumulatedMessage.Content { - if text, ok := block.AsAny().(anthropic.TextBlock); ok { - content += text.Text - } - } - - toolCalls := a.extractToolCalls(accumulatedMessage.Content) - tokenUsage := a.extractTokenUsage(accumulatedMessage.Usage) - - eventChan <- ProviderEvent{ - Type: EventComplete, - Response: &ProviderResponse{ - Content: content, - ToolCalls: toolCalls, - Usage: tokenUsage, - FinishReason: string(accumulatedMessage.StopReason), - }, - } + // For non-rate limit errors, report and exit + eventChan <- ProviderEvent{Type: EventError, Error: err} + return } } - - if stream.Err() != nil { - eventChan <- ProviderEvent{Type: EventError, Error: stream.Err()} - } }() return eventChan, nil @@ -311,3 +404,4 @@ func (a *anthropicProvider) convertToAnthropicMessages(messages []message.Messag return anthropicMessages } + diff --git a/internal/llm/provider/provider.go b/internal/llm/provider/provider.go index f4042973..cd3a7aa0 100644 --- a/internal/llm/provider/provider.go +++ b/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 { diff --git a/internal/tui/components/core/status.go b/internal/tui/components/core/status.go index bd362931..83ef07fe 100644 --- a/internal/tui/components/core/status.go +++ b/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, } } diff --git a/internal/tui/components/dialog/permission.go b/internal/tui/components/dialog/permission.go index 17b3fba0..465f475d 100644 --- a/internal/tui/components/dialog/permission.go +++ b/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, diff --git a/internal/tui/components/repl/editor.go b/internal/tui/components/repl/editor.go index cce966ca..d3e8d2c7 100644 --- a/internal/tui/components/repl/editor.go +++ b/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) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 15d97a11..314e0f17 100644 --- a/internal/tui/tui.go +++ b/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}) } diff --git a/internal/tui/util/util.go b/internal/tui/util/util.go index f6cf20d6..e006cbb4 100644 --- a/internal/tui/util/util.go +++ b/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{} )