feat: implement lsp errors

Kujtim Hoxha created

Change summary

internal/tui/components/chat/messages/messages.go |   2 
internal/tui/components/chat/sidebar/sidebar.go   | 122 ++++++++++++++++
internal/tui/components/core/helpers.go           |  16 +
internal/tui/page/chat/chat.go                    |   2 
internal/tui/styles/crush.go                      |   2 
internal/tui/styles/icons.go                      |   5 
todos.md                                          |   4 
7 files changed, 139 insertions(+), 14 deletions(-)

Detailed changes

internal/tui/components/chat/messages/messages.go 🔗

@@ -167,7 +167,7 @@ func (m *messageCmp) renderAssistantMessage() string {
 		case message.FinishReasonPermissionDenied:
 			infoMsg = "permission denied"
 		}
-		assistant := t.S().Muted.Render(fmt.Sprintf("⬡ %s (%s)", models.SupportedModels[m.message.Model].Name, infoMsg))
+		assistant := t.S().Muted.Render(fmt.Sprintf("%s %s (%s)", styles.ModelIcon, models.SupportedModels[m.message.Model].Name, infoMsg))
 		parts = append(parts, core.Section(assistant, m.textWidth()))
 	}
 

internal/tui/components/chat/sidebar/sidebar.go 🔗

@@ -1,11 +1,16 @@
 package sidebar
 
 import (
+	"fmt"
 	"os"
 	"strings"
 
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/llm/models"
+	"github.com/charmbracelet/crush/internal/logging"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/lsp/protocol"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
@@ -32,10 +37,13 @@ type sidebarCmp struct {
 	session       session.Session
 	logo          string
 	cwd           string
+	lspClients    map[string]*lsp.Client
 }
 
-func NewSidebarCmp() Sidebar {
-	return &sidebarCmp{}
+func NewSidebarCmp(lspClients map[string]*lsp.Client) Sidebar {
+	return &sidebarCmp{
+		lspClients: lspClients,
+	}
 }
 
 func (m *sidebarCmp) Init() tea.Cmd {
@@ -75,6 +83,8 @@ func (m *sidebarCmp) View() tea.View {
 	parts = append(parts,
 		m.cwd,
 		"",
+		m.currentModelBlock(),
+		"",
 		m.lspBlock(),
 		"",
 		m.mcpBlock(),
@@ -137,12 +147,44 @@ func (m *sidebarCmp) lspBlock() string {
 		if l.Disabled {
 			iconColor = t.FgMuted
 		}
+		lspErrs := map[protocol.DiagnosticSeverity]int{
+			protocol.SeverityError:       0,
+			protocol.SeverityWarning:     0,
+			protocol.SeverityHint:        0,
+			protocol.SeverityInformation: 0,
+		}
+		if client, ok := m.lspClients[n]; ok {
+			for _, diagnostics := range client.GetDiagnostics() {
+				for _, diagnostic := range diagnostics {
+					if severity, ok := lspErrs[diagnostic.Severity]; ok {
+						lspErrs[diagnostic.Severity] = severity + 1
+					}
+				}
+			}
+		}
+
+		errs := []string{}
+		if lspErrs[protocol.SeverityError] > 0 {
+			errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s%d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
+		}
+		if lspErrs[protocol.SeverityWarning] > 0 {
+			errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s%d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
+		}
+		if lspErrs[protocol.SeverityHint] > 0 {
+			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s%d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
+		}
+		if lspErrs[protocol.SeverityInformation] > 0 {
+			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s%d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
+		}
+
+		logging.Info("LSP Errors", "errors", errs)
 		lspList = append(lspList,
 			core.Status(
 				core.StatusOpts{
-					IconColor:   iconColor,
-					Title:       n,
-					Description: l.Command,
+					IconColor:    iconColor,
+					Title:        n,
+					Description:  l.Command,
+					ExtraContent: strings.Join(errs, " "),
 				},
 				m.width,
 			),
@@ -195,6 +237,76 @@ func (m *sidebarCmp) mcpBlock() string {
 	)
 }
 
+func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
+	t := styles.CurrentTheme()
+	// Format tokens in human-readable format (e.g., 110K, 1.2M)
+	var formattedTokens string
+	switch {
+	case tokens >= 1_000_000:
+		formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
+	case tokens >= 1_000:
+		formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
+	default:
+		formattedTokens = fmt.Sprintf("%d", tokens)
+	}
+
+	// Remove .0 suffix if present
+	if strings.HasSuffix(formattedTokens, ".0K") {
+		formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
+	}
+	if strings.HasSuffix(formattedTokens, ".0M") {
+		formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
+	}
+
+	percentage := (float64(tokens) / float64(contextWindow)) * 100
+
+	baseStyle := t.S().Base
+
+	formattedCost := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("$%.2f", cost))
+
+	formattedTokens = baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("(%s)", formattedTokens))
+	formattedPercentage := baseStyle.Foreground(t.FgSubtle).Render(fmt.Sprintf("%d%%", int(percentage)))
+	formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
+	if percentage > 80 {
+		// add the warning icon
+		formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
+	}
+
+	return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
+}
+
+func (s *sidebarCmp) currentModelBlock() string {
+	cfg := config.Get()
+	agentCfg := cfg.Agents[config.AgentCoder]
+	selectedModelID := agentCfg.Model
+	model := models.SupportedModels[selectedModelID]
+
+	t := styles.CurrentTheme()
+
+	modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
+	modelName := t.S().Text.Render(model.Name)
+	modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
+	parts := []string{
+		// section,
+		// "",
+		modelInfo,
+	}
+	if s.session.ID != "" {
+		parts = append(
+			parts,
+			"  "+formatTokensAndCost(
+				s.session.CompletionTokens+s.session.PromptTokens,
+				model.ContextWindow,
+				s.session.Cost,
+			),
+		)
+	}
+	return lipgloss.JoinVertical(
+		lipgloss.Left,
+		parts...,
+	)
+}
+
 func cwd() string {
 	cwd := config.WorkingDirectory()
 	t := styles.CurrentTheme()

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

@@ -42,6 +42,7 @@ type StatusOpts struct {
 	TitleColor       color.Color
 	Description      string
 	DescriptionColor color.Color
+	ExtraContent     string // Additional content to append after the description
 }
 
 func Status(ops StatusOpts, width int) string {
@@ -67,14 +68,23 @@ func Status(ops StatusOpts, width int) string {
 	icon = t.S().Base.Foreground(iconColor).Render(icon)
 	title = t.S().Base.Foreground(titleColor).Render(title)
 	if description != "" {
-		description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2, "…")
+		extraContent := len(ops.ExtraContent)
+		if extraContent > 0 {
+			extraContent += 1
+		}
+		description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContent, "…")
 	}
 	description = t.S().Base.Foreground(descriptionColor).Render(description)
-	return strings.Join([]string{
+	content := []string{
 		icon,
 		title,
 		description,
-	}, " ")
+	}
+	if ops.ExtraContent != "" {
+		content = append(content, ops.ExtraContent)
+	}
+
+	return strings.Join(content, " ")
 }
 
 type ButtonOpts struct {

internal/tui/page/chat/chat.go 🔗

@@ -209,7 +209,7 @@ func (p *chatPage) Bindings() []key.Binding {
 
 func NewChatPage(app *app.App) ChatPage {
 	sidebarContainer := layout.NewContainer(
-		sidebar.NewSidebarCmp(),
+		sidebar.NewSidebarCmp(app.LSPClients),
 		layout.WithPadding(1, 1, 1, 1),
 	)
 	editorContainer := layout.NewContainer(

internal/tui/styles/crush.go 🔗

@@ -32,7 +32,7 @@ func NewCrushTheme() *Theme {
 		// Status
 		Success: charmtone.Guac,
 		Error:   charmtone.Sriracha,
-		Warning: charmtone.Uni,
+		Warning: charmtone.Zest,
 		Info:    charmtone.Malibu,
 
 		// Colors

internal/tui/styles/icons.go 🔗

@@ -4,11 +4,12 @@ const (
 	CheckIcon    string = "✓"
 	ErrorIcon    string = "×"
 	WarningIcon  string = "⚠"
-	InfoIcon     string = ""
-	HintIcon     string = "i"
+	InfoIcon     string = "ⓘ"
+	HintIcon     string = "∵"
 	SpinnerIcon  string = "..."
 	LoadingIcon  string = "⟳"
 	DocumentIcon string = "🖼"
+	ModelIcon    string = "⬡"
 
 	// Tool call icons
 	ToolPending string = "●"

todos.md 🔗

@@ -3,7 +3,8 @@
 - [x] Implement help
   - [x] Show full help
   - [x] Make help dependent on the focused pane and page
-- [ ] Implement current model in the sidebar
+- [x] Implement current model in the sidebar
+- [x] Implement LSP errors
 - [ ] Implement changed files
 - [ ] Events when tool error
 - [ ] Support bash commands
@@ -23,6 +24,7 @@
   - [ ] Address UX issues
   - [ ] Fix issue with numbers (padding) view tool
 - [ ] Implement responsive mode
+- [ ] Update interactive mode to use the spinner
 - [ ] Revisit the core list component
   - [ ] This component has become super complex we might need to fix this.
 - [ ] Investigate ways to make the spinner less CPU intensive