feat: add diagnostics modal

Kujtim Hoxha created

Change summary

internal/lsp/client.go                                     |   2 
internal/tui/components/dialogs/commands/commands.go       |  53 +
internal/tui/components/dialogs/diagnostics/diagnostics.go | 264 ++++++++
internal/tui/components/dialogs/diagnostics/keys.go        |  66 ++
internal/tui/tui.go                                        |   9 
5 files changed, 380 insertions(+), 14 deletions(-)

Detailed changes

internal/lsp/client.go 🔗

@@ -56,7 +56,7 @@ func NewClient(ctx context.Context, command string, args ...string) (*Client, er
 	cmd := exec.CommandContext(ctx, command, args...)
 	// Copy env
 	cmd.Env = os.Environ()
-
+	
 	stdin, err := cmd.StdinPipe()
 	if err != nil {
 		return nil, fmt.Errorf("failed to create stdin pipe: %w", err)

internal/tui/components/dialogs/commands/commands.go 🔗

@@ -6,6 +6,7 @@ import (
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
 
+	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
 	"github.com/charmbracelet/crush/internal/tui/components/completions"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
@@ -48,21 +49,23 @@ type commandDialogCmp struct {
 	commandList  list.ListModel
 	keyMap       CommandsDialogKeyMap
 	help         help.Model
-	commandType  int       // SystemCommands or UserCommands
-	userCommands []Command // User-defined commands
-	sessionID    string    // Current session ID
+	commandType  int                        // SystemCommands or UserCommands
+	userCommands []Command                  // User-defined commands
+	sessionID    string                     // Current session ID
+	lspClients   map[string]*lsp.Client     // LSP clients for diagnostics check
 }
 
 type (
 	SwitchSessionsMsg    struct{}
 	SwitchModelMsg       struct{}
 	ToggleCompactModeMsg struct{}
+	ShowDiagnosticsMsg   struct{}
 	CompactMsg           struct {
 		SessionID string
 	}
 )
 
-func NewCommandDialog(sessionID string) CommandsDialog {
+func NewCommandDialog(sessionID string, lspClients map[string]*lsp.Client) CommandsDialog {
 	listKeyMap := list.DefaultKeyMap()
 	keyMap := DefaultCommandsDialogKeyMap()
 
@@ -85,12 +88,13 @@ func NewCommandDialog(sessionID string) CommandsDialog {
 	help := help.New()
 	help.Styles = t.S().Help
 	return &commandDialogCmp{
-		commandList: commandList,
-		width:       defaultWidth,
-		keyMap:      DefaultCommandsDialogKeyMap(),
-		help:        help,
-		commandType: SystemCommands,
-		sessionID:   sessionID,
+		commandList:  commandList,
+		width:        defaultWidth,
+		keyMap:       DefaultCommandsDialogKeyMap(),
+		help:         help,
+		commandType:  SystemCommands,
+		sessionID:    sessionID,
+		lspClients:   lspClients,
 	}
 }
 
@@ -226,6 +230,16 @@ func (c *commandDialogCmp) Position() (int, int) {
 	return row, col
 }
 
+func (c *commandDialogCmp) hasDiagnostics() bool {
+	for _, client := range c.lspClients {
+		diagnostics := client.GetDiagnostics()
+		if len(diagnostics) > 0 {
+			return true
+		}
+	}
+	return false
+}
+
 func (c *commandDialogCmp) defaultCommands() []Command {
 	commands := []Command{
 		{
@@ -273,7 +287,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
 		})
 	}
 
-	return append(commands, []Command{
+	baseCommands := []Command{
 		{
 			ID:          "switch_session",
 			Title:       "Switch Session",
@@ -291,7 +305,22 @@ func (c *commandDialogCmp) defaultCommands() []Command {
 				return util.CmdHandler(SwitchModelMsg{})
 			},
 		},
-	}...)
+	}
+
+	// Add diagnostics command only if there are diagnostics available
+	if c.hasDiagnostics() {
+		diagnosticsCmd := Command{
+			ID:          "diagnostics",
+			Title:       "Show Diagnostics",
+			Description: "View LSP diagnostics for the project",
+			Handler: func(cmd Command) tea.Cmd {
+				return util.CmdHandler(ShowDiagnosticsMsg{})
+			},
+		}
+		baseCommands = append([]Command{diagnosticsCmd}, baseCommands...)
+	}
+
+	return append(commands, baseCommands...)
 }
 
 func (c *commandDialogCmp) ID() dialogs.DialogID {

internal/tui/components/dialogs/diagnostics/diagnostics.go 🔗

@@ -0,0 +1,264 @@
+package diagnostics
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+
+	"github.com/charmbracelet/bubbles/v2/help"
+	"github.com/charmbracelet/bubbles/v2/key"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/lsp/protocol"
+	"github.com/charmbracelet/crush/internal/tui/components/completions"
+	"github.com/charmbracelet/crush/internal/tui/components/core"
+	"github.com/charmbracelet/crush/internal/tui/components/core/list"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/lipgloss/v2"
+)
+
+const (
+	DiagnosticsDialogID dialogs.DialogID = "diagnostics"
+
+	defaultWidth = 80
+)
+
+// DiagnosticItem represents a diagnostic entry
+type DiagnosticItem struct {
+	FilePath   string
+	Diagnostic protocol.Diagnostic
+	LSPName    string
+}
+
+// DiagnosticsDialog interface for the diagnostics dialog
+type DiagnosticsDialog interface {
+	dialogs.DialogModel
+}
+
+type diagnosticsDialogCmp struct {
+	width   int
+	wWidth  int
+	wHeight int
+
+	diagnosticsList list.ListModel
+	keyMap          KeyMap
+	help            help.Model
+	lspClients      map[string]*lsp.Client
+}
+
+func NewDiagnosticsDialogCmp(lspClients map[string]*lsp.Client) DiagnosticsDialog {
+	listKeyMap := list.DefaultKeyMap()
+	keyMap := DefaultKeyMap()
+
+	listKeyMap.Down.SetEnabled(false)
+	listKeyMap.Up.SetEnabled(false)
+	listKeyMap.HalfPageDown.SetEnabled(false)
+	listKeyMap.HalfPageUp.SetEnabled(false)
+	listKeyMap.Home.SetEnabled(false)
+	listKeyMap.End.SetEnabled(false)
+
+	listKeyMap.DownOneItem = keyMap.Next
+	listKeyMap.UpOneItem = keyMap.Previous
+
+	t := styles.CurrentTheme()
+	inputStyle := t.S().Base.Padding(0, 1, 0, 1)
+	diagnosticsList := list.New(
+		list.WithFilterable(true),
+		list.WithKeyMap(listKeyMap),
+		list.WithInputStyle(inputStyle),
+		list.WithWrapNavigation(true),
+	)
+	help := help.New()
+	help.Styles = t.S().Help
+
+	return &diagnosticsDialogCmp{
+		diagnosticsList: diagnosticsList,
+		width:           defaultWidth,
+		keyMap:          DefaultKeyMap(),
+		help:            help,
+		lspClients:      lspClients,
+	}
+}
+
+func (d *diagnosticsDialogCmp) Init() tea.Cmd {
+	d.loadDiagnostics()
+	return d.diagnosticsList.Init()
+}
+
+func (d *diagnosticsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		d.wWidth = msg.Width
+		d.wHeight = msg.Height
+		d.loadDiagnostics()
+		return d, d.diagnosticsList.SetSize(d.listWidth(), d.listHeight())
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, d.keyMap.Select):
+			selectedItemInx := d.diagnosticsList.SelectedIndex()
+			if selectedItemInx == list.NoSelection {
+				return d, nil
+			}
+			items := d.diagnosticsList.Items()
+			if selectedItem, ok := items[selectedItemInx].(completions.CompletionItem); ok {
+				if diagItem, ok := selectedItem.Value().(DiagnosticItem); ok {
+					// Open the file at the diagnostic location
+					_ = fmt.Sprintf("%s:%d:%d", 
+						diagItem.FilePath, 
+						diagItem.Diagnostic.Range.Start.Line+1, 
+						diagItem.Diagnostic.Range.Start.Character+1)
+					
+					return d, tea.Sequence(
+						util.CmdHandler(dialogs.CloseDialogMsg{}),
+						// You might want to add a message to open the file/location
+						// For now, we'll just close the dialog
+					)
+				}
+			}
+			return d, nil
+		case key.Matches(msg, d.keyMap.Close):
+			return d, util.CmdHandler(dialogs.CloseDialogMsg{})
+		default:
+			u, cmd := d.diagnosticsList.Update(msg)
+			d.diagnosticsList = u.(list.ListModel)
+			return d, cmd
+		}
+	}
+	return d, nil
+}
+
+func (d *diagnosticsDialogCmp) View() tea.View {
+	t := styles.CurrentTheme()
+	listView := d.diagnosticsList.View()
+	content := lipgloss.JoinVertical(
+		lipgloss.Left,
+		t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Diagnostics", d.width-5)),
+		listView.String(),
+		"",
+		t.S().Base.Width(d.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(d.help.View(d.keyMap)),
+	)
+	v := tea.NewView(d.style().Render(content))
+	if listView.Cursor() != nil {
+		c := d.moveCursor(listView.Cursor())
+		v.SetCursor(c)
+	}
+	return v
+}
+
+func (d *diagnosticsDialogCmp) style() lipgloss.Style {
+	t := styles.CurrentTheme()
+	return t.S().Base.
+		Width(d.width).
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(t.BorderFocus)
+}
+
+func (d *diagnosticsDialogCmp) listWidth() int {
+	return defaultWidth - 2
+}
+
+func (d *diagnosticsDialogCmp) listHeight() int {
+	listHeight := len(d.diagnosticsList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
+	return min(listHeight, d.wHeight/2)
+}
+
+func (d *diagnosticsDialogCmp) Position() (int, int) {
+	row := d.wHeight/4 - 2 // just a bit above the center
+	col := d.wWidth / 2
+	col -= d.width / 2
+	return row, col
+}
+
+func (d *diagnosticsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
+	row, col := d.Position()
+	offset := row + 3 // Border + title
+	cursor.Y += offset
+	cursor.X = cursor.X + col + 2
+	return cursor
+}
+
+func (d *diagnosticsDialogCmp) ID() dialogs.DialogID {
+	return DiagnosticsDialogID
+}
+
+func (d *diagnosticsDialogCmp) loadDiagnostics() {
+	diagnosticItems := []util.Model{}
+	
+	// Group diagnostics by LSP
+	lspDiagnostics := make(map[string][]DiagnosticItem)
+	
+	for lspName, client := range d.lspClients {
+		diagnostics := client.GetDiagnostics()
+		var items []DiagnosticItem
+		
+		for location, diags := range diagnostics {
+			for _, diag := range diags {
+				items = append(items, DiagnosticItem{
+					FilePath:   location.Path(),
+					Diagnostic: diag,
+					LSPName:    lspName,
+				})
+			}
+		}
+		
+		// Sort diagnostics by severity (errors first) then by file path
+		sort.Slice(items, func(i, j int) bool {
+			iSeverity := items[i].Diagnostic.Severity
+			jSeverity := items[j].Diagnostic.Severity
+			if iSeverity != jSeverity {
+				return iSeverity < jSeverity // Lower severity number = higher priority
+			}
+			return items[i].FilePath < items[j].FilePath
+		})
+		
+		if len(items) > 0 {
+			lspDiagnostics[lspName] = items
+		}
+	}
+	
+	// Add sections for each LSP with diagnostics
+	for lspName, items := range lspDiagnostics {
+		// Add section header
+		diagnosticItems = append(diagnosticItems, commands.NewItemSection(lspName))
+		
+		// Add diagnostic items
+		for _, item := range items {
+			title := d.formatDiagnosticTitle(item)
+			diagnosticItems = append(diagnosticItems, completions.NewCompletionItem(title, item))
+		}
+	}
+	
+	d.diagnosticsList.SetItems(diagnosticItems)
+}
+
+func (d *diagnosticsDialogCmp) formatDiagnosticTitle(item DiagnosticItem) string {
+	severity := "Info"
+	switch item.Diagnostic.Severity {
+	case protocol.SeverityError:
+		severity = "Error"
+	case protocol.SeverityWarning:
+		severity = "Warn"
+	case protocol.SeverityHint:
+		severity = "Hint"
+	}
+	
+	// Extract filename from path
+	parts := strings.Split(item.FilePath, "/")
+	filename := parts[len(parts)-1]
+	
+	location := fmt.Sprintf("%s:%d:%d", 
+		filename, 
+		item.Diagnostic.Range.Start.Line+1, 
+		item.Diagnostic.Range.Start.Character+1)
+	
+	// Truncate message if too long
+	message := item.Diagnostic.Message
+	if len(message) > 60 {
+		message = message[:57] + "..."
+	}
+	
+	return fmt.Sprintf("[%s] %s - %s", severity, location, message)
+}

internal/tui/components/dialogs/diagnostics/keys.go 🔗

@@ -0,0 +1,66 @@
+package diagnostics
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+)
+
+type KeyMap struct {
+	Select,
+	Next,
+	Previous,
+	Close key.Binding
+}
+
+func DefaultKeyMap() KeyMap {
+	return KeyMap{
+		Select: key.NewBinding(
+			key.WithKeys("enter", "ctrl+y"),
+			key.WithHelp("enter", "select"),
+		),
+		Next: key.NewBinding(
+			key.WithKeys("down", "ctrl+n"),
+			key.WithHelp("↓", "next item"),
+		),
+		Previous: key.NewBinding(
+			key.WithKeys("up", "ctrl+p"),
+			key.WithHelp("↑", "previous item"),
+		),
+		Close: key.NewBinding(
+			key.WithKeys("esc"),
+			key.WithHelp("esc", "cancel"),
+		),
+	}
+}
+
+// KeyBindings implements layout.KeyMapProvider
+func (k KeyMap) KeyBindings() []key.Binding {
+	return []key.Binding{
+		k.Select,
+		k.Next,
+		k.Previous,
+		k.Close,
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (k KeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := k.KeyBindings()
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k KeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		key.NewBinding(
+			key.WithKeys("down", "up"),
+			key.WithHelp("↑↓", "choose"),
+		),
+		k.Select,
+		k.Close,
+	}
+}

internal/tui/tui.go 🔗

@@ -19,6 +19,7 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/compact"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs/diagnostics"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
 	initDialog "github.com/charmbracelet/crush/internal/tui/components/dialogs/init"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
@@ -164,6 +165,12 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				Model: models.NewModelDialogCmp(),
 			},
 		)
+	case commands.ShowDiagnosticsMsg:
+		return a, util.CmdHandler(
+			dialogs.OpenDialogMsg{
+				Model: diagnostics.NewDiagnosticsDialogCmp(a.app.LSPClients),
+			},
+		)
 	// Compact
 	case commands.CompactMsg:
 		return a, util.CmdHandler(dialogs.OpenDialogMsg{
@@ -343,7 +350,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 			return util.CmdHandler(dialogs.CloseDialogMsg{})
 		}
 		return util.CmdHandler(dialogs.OpenDialogMsg{
-			Model: commands.NewCommandDialog(a.selectedSessionID),
+			Model: commands.NewCommandDialog(a.selectedSessionID, a.app.LSPClients),
 		})
 	case key.Matches(msg, a.keyMap.Sessions):
 		if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {