@@ -5,7 +5,6 @@ import (
55 "fmt"
66 "os"
77 "slices"
8− "sort"
98 "strings"
109
1110 tea "github.com/charmbracelet/bubbletea/v2"
@@ -13,21 +12,21 @@ import (
1312 "github.com/charmbracelet/crush/internal/config"
1413 "github.com/charmbracelet/crush/internal/csync"
1514 "github.com/charmbracelet/crush/internal/diff"
16− "github.com/charmbracelet/crush/internal/fsext"
1715 "github.com/charmbracelet/crush/internal/history"
1816 "github.com/charmbracelet/crush/internal/lsp"
19− "github.com/charmbracelet/crush/internal/lsp/protocol"
2017 "github.com/charmbracelet/crush/internal/pubsub"
2118 "github.com/charmbracelet/crush/internal/session"
2219 "github.com/charmbracelet/crush/internal/tui/components/chat"
2320 "github.com/charmbracelet/crush/internal/tui/components/core"
2421 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
22+ "github.com/charmbracelet/crush/internal/tui/components/files"
2523 "github.com/charmbracelet/crush/internal/tui/components/logo"
24+ lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
25+ "github.com/charmbracelet/crush/internal/tui/components/mcp"
2626 "github.com/charmbracelet/crush/internal/tui/styles"
2727 "github.com/charmbracelet/crush/internal/tui/util"
2828 "github.com/charmbracelet/crush/internal/version"
2929 "github.com/charmbracelet/lipgloss/v2"
30− "github.com/charmbracelet/x/ansi"
3130 "golang.org/x/text/cases"
3231 "golang.org/x/text/language"
3332)
@@ -382,459 +381,125 @@ func (m *sidebarCmp) renderSectionsHorizontal() string {
382381
383382// filesBlockCompact renders the files block with limited width and height for horizontal layout
384383func (m *sidebarCmp) filesBlockCompact(maxWidth int) string {
385− t := styles.CurrentTheme()
386−
387− section := t.S().Subtle.Render("Modified Files")
388−
389− files := slices.Collect(m.files.Seq())
390−
391− if len(files) == 0 {
392− content := lipgloss.JoinVertical(
393− lipgloss.Left,
394− section,
395− "",
396− t.S().Base.Foreground(t.Border).Render("None"),
397− )
398− return lipgloss.NewStyle().Width(maxWidth).Render(content)
384+ // Convert map to slice and handle type conversion
385+ sessionFiles := slices.Collect(m.files.Seq())
386+ fileSlice := make([]files.SessionFile, len(sessionFiles))
387+ for i, sf := range sessionFiles {
388+ fileSlice[i] = files.SessionFile{
389+ History: files.FileHistory{
390+ InitialVersion: sf.History.initialVersion,
391+ LatestVersion: sf.History.latestVersion,
392+ },
393+ FilePath: sf.FilePath,
394+ Additions: sf.Additions,
395+ Deletions: sf.Deletions,
396+ }
399397 }
400398
401− fileList := []string{section, ""}
402− sort.Slice(files, func(i, j int) bool {
403− return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
404− })
405−
406− // Limit items for horizontal layout - use less space
407− maxItems := min(5, len(files))
399+ // Limit items for horizontal layout
400+ maxItems := min(5, len(fileSlice))
408401 availableHeight := m.height - 8 // Reserve space for header and other content
409402 if availableHeight > 0 {
410403 maxItems = min(maxItems, availableHeight)
411404 }
412405
413− filesShown := 0
414− for _, file := range files {
415− if file.Additions == 0 && file.Deletions == 0 {
416− continue
417− }
418− if filesShown >= maxItems {
419− break
420− }
421−
422− var statusParts []string
423− if file.Additions > 0 {
424− statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
425− }
426− if file.Deletions > 0 {
427− statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
428− }
429−
430− extraContent := strings.Join(statusParts, " ")
431− cwd := config.Get().WorkingDir() + string(os.PathSeparator)
432− filePath := file.FilePath
433− filePath = strings.TrimPrefix(filePath, cwd)
434− filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
435− filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…")
436−
437− fileList = append(fileList,
438− core.Status(
439− core.StatusOpts{
440− IconColor: t.FgMuted,
441− NoIcon: true,
442− Title: filePath,
443− ExtraContent: extraContent,
444− },
445− maxWidth,
446− ),
447− )
448− filesShown++
449− }
450−
451− // Add "..." indicator if there are more files
452− totalFilesWithChanges := 0
453− for _, file := range files {
454− if file.Additions > 0 || file.Deletions > 0 {
455− totalFilesWithChanges++
456− }
457− }
458− if totalFilesWithChanges > maxItems {
459− fileList = append(fileList, t.S().Base.Foreground(t.FgMuted).Render("…"))
460− }
461−
462− content := lipgloss.JoinVertical(lipgloss.Left, fileList...)
463− return lipgloss.NewStyle().Width(maxWidth).Render(content)
406+ return files.RenderFileBlock(fileSlice, files.RenderOptions{
407+ MaxWidth: maxWidth,
408+ MaxItems: maxItems,
409+ ShowSection: true,
410+ SectionName: "Modified Files",
411+ }, true)
464412}
465413
466414// lspBlockCompact renders the LSP block with limited width and height for horizontal layout
467415func (m *sidebarCmp) lspBlockCompact(maxWidth int) string {
468− t := styles.CurrentTheme()
469−
470− section := t.S().Subtle.Render("LSPs")
471−
472− lspList := []string{section, ""}
473−
474− lsp := config.Get().LSP.Sorted()
475− if len(lsp) == 0 {
476− content := lipgloss.JoinVertical(
477− lipgloss.Left,
478− section,
479− "",
480− t.S().Base.Foreground(t.Border).Render("None"),
481− )
482− return lipgloss.NewStyle().Width(maxWidth).Render(content)
483− }
484−
485416 // Limit items for horizontal layout
486− maxItems := min(5, len(lsp))
417+ lspConfigs := config.Get().LSP.Sorted()
418+ maxItems := min(5, len(lspConfigs))
487419 availableHeight := m.height - 8
488420 if availableHeight > 0 {
489421 maxItems = min(maxItems, availableHeight)
490422 }
491423
492− for i, l := range lsp {
493− if i >= maxItems {
494− break
495− }
496−
497− iconColor := t.Success
498− if l.LSP.Disabled {
499− iconColor = t.FgMuted
500− }
501−
502− lspErrs := map[protocol.DiagnosticSeverity]int{
503− protocol.SeverityError: 0,
504− protocol.SeverityWarning: 0,
505− protocol.SeverityHint: 0,
506− protocol.SeverityInformation: 0,
507− }
508− if client, ok := m.lspClients[l.Name]; ok {
509− for _, diagnostics := range client.GetDiagnostics() {
510− for _, diagnostic := range diagnostics {
511− if severity, ok := lspErrs[diagnostic.Severity]; ok {
512− lspErrs[diagnostic.Severity] = severity + 1
513− }
514− }
515− }
516− }
517−
518− errs := []string{}
519− if lspErrs[protocol.SeverityError] > 0 {
520− errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
521− }
522− if lspErrs[protocol.SeverityWarning] > 0 {
523− errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
524− }
525− if lspErrs[protocol.SeverityHint] > 0 {
526− errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
527− }
528− if lspErrs[protocol.SeverityInformation] > 0 {
529− errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
530− }
531−
532− lspList = append(lspList,
533− core.Status(
534− core.StatusOpts{
535− IconColor: iconColor,
536− Title: l.Name,
537− Description: l.LSP.Command,
538− ExtraContent: strings.Join(errs, " "),
539− },
540− maxWidth,
541− ),
542− )
543− }
544−
545− // Add "..." indicator if there are more LSPs
546− if len(lsp) > maxItems {
547− lspList = append(lspList, t.S().Base.Foreground(t.FgMuted).Render("…"))
548− }
549−
550− content := lipgloss.JoinVertical(lipgloss.Left, lspList...)
551− return lipgloss.NewStyle().Width(maxWidth).Render(content)
424+ return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
425+ MaxWidth: maxWidth,
426+ MaxItems: maxItems,
427+ ShowSection: true,
428+ SectionName: "LSPs",
429+ }, true)
552430}
553431
554432// mcpBlockCompact renders the MCP block with limited width and height for horizontal layout
555433func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
556− t := styles.CurrentTheme()
557−
558− section := t.S().Subtle.Render("MCPs")
559−
560− mcpList := []string{section, ""}
561−
562− mcps := config.Get().MCP.Sorted()
563− if len(mcps) == 0 {
564− content := lipgloss.JoinVertical(
565− lipgloss.Left,
566− section,
567− "",
568− t.S().Base.Foreground(t.Border).Render("None"),
569− )
570− return lipgloss.NewStyle().Width(maxWidth).Render(content)
571− }
572−
573434 // Limit items for horizontal layout
574− maxItems := min(5, len(mcps))
435+ maxItems := min(5, len(config.Get().MCP.Sorted()))
575436 availableHeight := m.height - 8
576437 if availableHeight > 0 {
577438 maxItems = min(maxItems, availableHeight)
578439 }
579440
580− for i, l := range mcps {
581− if i >= maxItems {
582− break
583− }
584−
585− iconColor := t.Success
586− if l.MCP.Disabled {
587− iconColor = t.FgMuted
588− }
589−
590− mcpList = append(mcpList,
591− core.Status(
592− core.StatusOpts{
593− IconColor: iconColor,
594− Title: l.Name,
595− Description: l.MCP.Command,
596− },
597− maxWidth,
598− ),
599− )
600− }
601−
602− // Add "..." indicator if there are more MCPs
603− if len(mcps) > maxItems {
604− mcpList = append(mcpList, t.S().Base.Foreground(t.FgMuted).Render("…"))
605− }
606−
607− content := lipgloss.JoinVertical(lipgloss.Left, mcpList...)
608− return lipgloss.NewStyle().Width(maxWidth).Render(content)
441+ return mcp.RenderMCPBlock(mcp.RenderOptions{
442+ MaxWidth: maxWidth,
443+ MaxItems: maxItems,
444+ ShowSection: true,
445+ SectionName: "MCPs",
446+ }, true)
609447}
610448
611449func (m *sidebarCmp) filesBlock() string {
612− t := styles.CurrentTheme()
613−
614− section := t.S().Subtle.Render(
615− core.Section("Modified Files", m.getMaxWidth()),
616− )
617−
618− files := slices.Collect(m.files.Seq())
619− if len(files) == 0 {
620− return lipgloss.JoinVertical(
621− lipgloss.Left,
622− section,
623− "",
624− t.S().Base.Foreground(t.Border).Render("None"),
625− )
450+ // Convert map to slice and handle type conversion
451+ sessionFiles := slices.Collect(m.files.Seq())
452+ fileSlice := make([]files.SessionFile, len(sessionFiles))
453+ for i, sf := range sessionFiles {
454+ fileSlice[i] = files.SessionFile{
455+ History: files.FileHistory{
456+ InitialVersion: sf.History.initialVersion,
457+ LatestVersion: sf.History.latestVersion,
458+ },
459+ FilePath: sf.FilePath,
460+ Additions: sf.Additions,
461+ Deletions: sf.Deletions,
462+ }
626463 }
627464
628− fileList := []string{section, ""}
629− // order files by the latest version's created time
630− sort.Slice(files, func(i, j int) bool {
631− return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
632− })
633−
634465 // Limit the number of files shown
635466 maxFiles, _, _ := m.getDynamicLimits()
636− maxFiles = min(len(files), maxFiles)
637− filesShown := 0
638−
639− for _, file := range files {
640− if file.Additions == 0 && file.Deletions == 0 {
641− continue // skip files with no changes
642− }
643− if filesShown >= maxFiles {
644− break
645− }
646−
647− var statusParts []string
648− if file.Additions > 0 {
649− statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
650− }
651− if file.Deletions > 0 {
652− statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
653− }
654−
655− extraContent := strings.Join(statusParts, " ")
656− cwd := config.Get().WorkingDir() + string(os.PathSeparator)
657− filePath := file.FilePath
658− filePath = strings.TrimPrefix(filePath, cwd)
659− filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
660− filePath = ansi.Truncate(filePath, m.getMaxWidth()-lipgloss.Width(extraContent)-2, "…")
661− fileList = append(fileList,
662− core.Status(
663− core.StatusOpts{
664− IconColor: t.FgMuted,
665− NoIcon: true,
666− Title: filePath,
667− ExtraContent: extraContent,
668− },
669− m.getMaxWidth(),
670− ),
671− )
672− filesShown++
673− }
674−
675− // Add indicator if there are more files
676− totalFilesWithChanges := 0
677− for _, file := range files {
678− if file.Additions > 0 || file.Deletions > 0 {
679− totalFilesWithChanges++
680− }
681− }
682− if totalFilesWithChanges > maxFiles {
683− remaining := totalFilesWithChanges - maxFiles
684− fileList = append(fileList,
685− t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
686− )
687− }
688−
689− return lipgloss.JoinVertical(
690− lipgloss.Left,
691− fileList...,
692− )
467+ maxFiles = min(len(fileSlice), maxFiles)
468+
469+ return files.RenderFileBlock(fileSlice, files.RenderOptions{
470+ MaxWidth: m.getMaxWidth(),
471+ MaxItems: maxFiles,
472+ ShowSection: true,
473+ SectionName: core.Section("Modified Files", m.getMaxWidth()),
474+ }, true)
693475}
694476
695477func (m *sidebarCmp) lspBlock() string {
696− t := styles.CurrentTheme()
697−
698− section := t.S().Subtle.Render(
699− core.Section("LSPs", m.getMaxWidth()),
700− )
701−
702− lspList := []string{section, ""}
703−
704− lsp := config.Get().LSP.Sorted()
705− if len(lsp) == 0 {
706− return lipgloss.JoinVertical(
707− lipgloss.Left,
708− section,
709− "",
710− t.S().Base.Foreground(t.Border).Render("None"),
711− )
712− }
713−
714478 // Limit the number of LSPs shown
715479 _, maxLSPs, _ := m.getDynamicLimits()
716− maxLSPs = min(len(lsp), maxLSPs)
717− for i, l := range lsp {
718− if i >= maxLSPs {
719− break
720− }
721−
722− iconColor := t.Success
723− if l.LSP.Disabled {
724− iconColor = t.FgMuted
725− }
726− lspErrs := map[protocol.DiagnosticSeverity]int{
727− protocol.SeverityError: 0,
728− protocol.SeverityWarning: 0,
729− protocol.SeverityHint: 0,
730− protocol.SeverityInformation: 0,
731− }
732− if client, ok := m.lspClients[l.Name]; ok {
733− for _, diagnostics := range client.GetDiagnostics() {
734− for _, diagnostic := range diagnostics {
735− if severity, ok := lspErrs[diagnostic.Severity]; ok {
736− lspErrs[diagnostic.Severity] = severity + 1
737− }
738− }
739− }
740− }
741−
742− errs := []string{}
743− if lspErrs[protocol.SeverityError] > 0 {
744− errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
745− }
746− if lspErrs[protocol.SeverityWarning] > 0 {
747− errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
748− }
749− if lspErrs[protocol.SeverityHint] > 0 {
750− errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
751− }
752− if lspErrs[protocol.SeverityInformation] > 0 {
753− errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
754− }
755−
756− lspList = append(lspList,
757− core.Status(
758− core.StatusOpts{
759− IconColor: iconColor,
760− Title: l.Name,
761− Description: l.LSP.Command,
762− ExtraContent: strings.Join(errs, " "),
763− },
764− m.getMaxWidth(),
765− ),
766− )
767− }
768−
769− // Add indicator if there are more LSPs
770− if len(lsp) > maxLSPs {
771− remaining := len(lsp) - maxLSPs
772− lspList = append(lspList,
773− t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
774− )
775− }
776−
777− return lipgloss.JoinVertical(
778− lipgloss.Left,
779− lspList...,
780− )
480+ lspConfigs := config.Get().LSP.Sorted()
481+ maxLSPs = min(len(lspConfigs), maxLSPs)
482+
483+ return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
484+ MaxWidth: m.getMaxWidth(),
485+ MaxItems: maxLSPs,
486+ ShowSection: true,
487+ SectionName: core.Section("LSPs", m.getMaxWidth()),
488+ }, true)
781489}
782490
783491func (m *sidebarCmp) mcpBlock() string {
784− t := styles.CurrentTheme()
785−
786− section := t.S().Subtle.Render(
787− core.Section("MCPs", m.getMaxWidth()),
788− )
789−
790− mcpList := []string{section, ""}
791−
792− mcps := config.Get().MCP.Sorted()
793− if len(mcps) == 0 {
794− return lipgloss.JoinVertical(
795− lipgloss.Left,
796− section,
797− "",
798− t.S().Base.Foreground(t.Border).Render("None"),
799− )
800− }
801−
802492 // Limit the number of MCPs shown
803493 _, _, maxMCPs := m.getDynamicLimits()
494+ mcps := config.Get().MCP.Sorted()
804495 maxMCPs = min(len(mcps), maxMCPs)
805− for i, l := range mcps {
806− if i >= maxMCPs {
807− break
808− }
809−
810− iconColor := t.Success
811− if l.MCP.Disabled {
812− iconColor = t.FgMuted
813− }
814− mcpList = append(mcpList,
815− core.Status(
816− core.StatusOpts{
817− IconColor: iconColor,
818− Title: l.Name,
819− Description: l.MCP.Command,
820− },
821− m.getMaxWidth(),
822− ),
823− )
824− }
825−
826− // Add indicator if there are more MCPs
827− if len(mcps) > maxMCPs {
828− remaining := len(mcps) - maxMCPs
829− mcpList = append(mcpList,
830− t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
831− )
832− }
833496
834− return lipgloss.JoinVertical(
835− lipgloss.Left,
836− mcpList...,
837− )
497+ return mcp.RenderMCPBlock(mcp.RenderOptions{
498+ MaxWidth: m.getMaxWidth(),
499+ MaxItems: maxMCPs,
500+ ShowSection: true,
501+ SectionName: core.Section("MCPs", m.getMaxWidth()),
502+ }, true)
838503}
839504
840505func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {