@@ -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,465 +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)
399− }
400−
401− fileList := []string{section, ""}
402− sort.Slice(files, func(i, j int) bool {
403− if files[i].History.latestVersion.CreatedAt == files[j].History.latestVersion.CreatedAt {
404− return files[i].FilePath < files[j].FilePath
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,
405396 }
406− return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
407− })
397+ }
408398
409− // Limit items for horizontal layout - use less space
410− maxItems := min(5, len(files))
399+ // Limit items for horizontal layout
400+ maxItems := min(5, len(fileSlice))
411401 availableHeight := m.height - 8 // Reserve space for header and other content
412402 if availableHeight > 0 {
413403 maxItems = min(maxItems, availableHeight)
414404 }
415405
416− filesShown := 0
417− for _, file := range files {
418− if file.Additions == 0 && file.Deletions == 0 {
419− continue
420− }
421− if filesShown >= maxItems {
422− break
423− }
424−
425− var statusParts []string
426− if file.Additions > 0 {
427− statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
428− }
429− if file.Deletions > 0 {
430− statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
431− }
432−
433− extraContent := strings.Join(statusParts, " ")
434− cwd := config.Get().WorkingDir() + string(os.PathSeparator)
435− filePath := file.FilePath
436− filePath = strings.TrimPrefix(filePath, cwd)
437− filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
438− filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…")
439−
440− fileList = append(fileList,
441− core.Status(
442− core.StatusOpts{
443− IconColor: t.FgMuted,
444− NoIcon: true,
445− Title: filePath,
446− ExtraContent: extraContent,
447− },
448− maxWidth,
449− ),
450− )
451− filesShown++
452− }
453−
454− // Add "..." indicator if there are more files
455− totalFilesWithChanges := 0
456− for _, file := range files {
457− if file.Additions > 0 || file.Deletions > 0 {
458− totalFilesWithChanges++
459− }
460− }
461− if totalFilesWithChanges > maxItems {
462− fileList = append(fileList, t.S().Base.Foreground(t.FgMuted).Render("…"))
463− }
464−
465− content := lipgloss.JoinVertical(lipgloss.Left, fileList...)
466− 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)
467412}
468413
469414// lspBlockCompact renders the LSP block with limited width and height for horizontal layout
470415func (m *sidebarCmp) lspBlockCompact(maxWidth int) string {
471− t := styles.CurrentTheme()
472−
473− section := t.S().Subtle.Render("LSPs")
474−
475− lspList := []string{section, ""}
476−
477− lsp := config.Get().LSP.Sorted()
478− if len(lsp) == 0 {
479− content := lipgloss.JoinVertical(
480− lipgloss.Left,
481− section,
482− "",
483− t.S().Base.Foreground(t.Border).Render("None"),
484− )
485− return lipgloss.NewStyle().Width(maxWidth).Render(content)
486− }
487−
488416 // Limit items for horizontal layout
489− maxItems := min(5, len(lsp))
417+ lspConfigs := config.Get().LSP.Sorted()
418+ maxItems := min(5, len(lspConfigs))
490419 availableHeight := m.height - 8
491420 if availableHeight > 0 {
492421 maxItems = min(maxItems, availableHeight)
493422 }
494423
495− for i, l := range lsp {
496− if i >= maxItems {
497− break
498− }
499−
500− iconColor := t.Success
501− if l.LSP.Disabled {
502− iconColor = t.FgMuted
503− }
504−
505− lspErrs := map[protocol.DiagnosticSeverity]int{
506− protocol.SeverityError: 0,
507− protocol.SeverityWarning: 0,
508− protocol.SeverityHint: 0,
509− protocol.SeverityInformation: 0,
510− }
511− if client, ok := m.lspClients[l.Name]; ok {
512− for _, diagnostics := range client.GetDiagnostics() {
513− for _, diagnostic := range diagnostics {
514− if severity, ok := lspErrs[diagnostic.Severity]; ok {
515− lspErrs[diagnostic.Severity] = severity + 1
516− }
517− }
518− }
519− }
520−
521− errs := []string{}
522− if lspErrs[protocol.SeverityError] > 0 {
523− errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
524− }
525− if lspErrs[protocol.SeverityWarning] > 0 {
526− errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
527− }
528− if lspErrs[protocol.SeverityHint] > 0 {
529− errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
530− }
531− if lspErrs[protocol.SeverityInformation] > 0 {
532− errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
533− }
534−
535− lspList = append(lspList,
536− core.Status(
537− core.StatusOpts{
538− IconColor: iconColor,
539− Title: l.Name,
540− Description: l.LSP.Command,
541− ExtraContent: strings.Join(errs, " "),
542− },
543− maxWidth,
544− ),
545− )
546− }
547−
548− // Add "..." indicator if there are more LSPs
549− if len(lsp) > maxItems {
550− lspList = append(lspList, t.S().Base.Foreground(t.FgMuted).Render("…"))
551− }
552−
553− content := lipgloss.JoinVertical(lipgloss.Left, lspList...)
554− 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)
555430}
556431
557432// mcpBlockCompact renders the MCP block with limited width and height for horizontal layout
558433func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
559− t := styles.CurrentTheme()
560−
561− section := t.S().Subtle.Render("MCPs")
562−
563− mcpList := []string{section, ""}
564−
565− mcps := config.Get().MCP.Sorted()
566− if len(mcps) == 0 {
567− content := lipgloss.JoinVertical(
568− lipgloss.Left,
569− section,
570− "",
571− t.S().Base.Foreground(t.Border).Render("None"),
572− )
573− return lipgloss.NewStyle().Width(maxWidth).Render(content)
574− }
575−
576434 // Limit items for horizontal layout
577− maxItems := min(5, len(mcps))
435+ maxItems := min(5, len(config.Get().MCP.Sorted()))
578436 availableHeight := m.height - 8
579437 if availableHeight > 0 {
580438 maxItems = min(maxItems, availableHeight)
581439 }
582440
583− for i, l := range mcps {
584− if i >= maxItems {
585− break
586− }
587−
588− iconColor := t.Success
589− if l.MCP.Disabled {
590− iconColor = t.FgMuted
591− }
592−
593− mcpList = append(mcpList,
594− core.Status(
595− core.StatusOpts{
596− IconColor: iconColor,
597− Title: l.Name,
598− Description: l.MCP.Command,
599− },
600− maxWidth,
601− ),
602− )
603− }
604−
605− // Add "..." indicator if there are more MCPs
606− if len(mcps) > maxItems {
607− mcpList = append(mcpList, t.S().Base.Foreground(t.FgMuted).Render("…"))
608− }
609−
610− content := lipgloss.JoinVertical(lipgloss.Left, mcpList...)
611− 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)
612447}
613448
614449func (m *sidebarCmp) filesBlock() string {
615− t := styles.CurrentTheme()
616−
617− section := t.S().Subtle.Render(
618− core.Section("Modified Files", m.getMaxWidth()),
619− )
620−
621− files := slices.Collect(m.files.Seq())
622− if len(files) == 0 {
623− return lipgloss.JoinVertical(
624− lipgloss.Left,
625− section,
626− "",
627− t.S().Base.Foreground(t.Border).Render("None"),
628− )
629− }
630−
631− fileList := []string{section, ""}
632− // order files by the latest version's created time, then by path for stability
633− sort.Slice(files, func(i, j int) bool {
634− if files[i].History.latestVersion.CreatedAt == files[j].History.latestVersion.CreatedAt {
635− return files[i].FilePath < files[j].FilePath
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,
636462 }
637− return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
638− })
463+ }
639464
640465 // Limit the number of files shown
641466 maxFiles, _, _ := m.getDynamicLimits()
642− maxFiles = min(len(files), maxFiles)
643− filesShown := 0
644−
645− for _, file := range files {
646− if file.Additions == 0 && file.Deletions == 0 {
647− continue // skip files with no changes
648− }
649− if filesShown >= maxFiles {
650− break
651− }
652−
653− var statusParts []string
654− if file.Additions > 0 {
655− statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
656− }
657− if file.Deletions > 0 {
658− statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
659− }
660−
661− extraContent := strings.Join(statusParts, " ")
662− cwd := config.Get().WorkingDir() + string(os.PathSeparator)
663− filePath := file.FilePath
664− filePath = strings.TrimPrefix(filePath, cwd)
665− filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
666− filePath = ansi.Truncate(filePath, m.getMaxWidth()-lipgloss.Width(extraContent)-2, "…")
667− fileList = append(fileList,
668− core.Status(
669− core.StatusOpts{
670− IconColor: t.FgMuted,
671− NoIcon: true,
672− Title: filePath,
673− ExtraContent: extraContent,
674− },
675− m.getMaxWidth(),
676− ),
677− )
678− filesShown++
679− }
680−
681− // Add indicator if there are more files
682− totalFilesWithChanges := 0
683− for _, file := range files {
684− if file.Additions > 0 || file.Deletions > 0 {
685− totalFilesWithChanges++
686− }
687− }
688− if totalFilesWithChanges > maxFiles {
689− remaining := totalFilesWithChanges - maxFiles
690− fileList = append(fileList,
691− t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
692− )
693− }
694−
695− return lipgloss.JoinVertical(
696− lipgloss.Left,
697− fileList...,
698− )
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)
699475}
700476
701477func (m *sidebarCmp) lspBlock() string {
702− t := styles.CurrentTheme()
703−
704− section := t.S().Subtle.Render(
705− core.Section("LSPs", m.getMaxWidth()),
706− )
707−
708− lspList := []string{section, ""}
709−
710− lsp := config.Get().LSP.Sorted()
711− if len(lsp) == 0 {
712− return lipgloss.JoinVertical(
713− lipgloss.Left,
714− section,
715− "",
716− t.S().Base.Foreground(t.Border).Render("None"),
717− )
718− }
719−
720478 // Limit the number of LSPs shown
721479 _, maxLSPs, _ := m.getDynamicLimits()
722− maxLSPs = min(len(lsp), maxLSPs)
723− for i, l := range lsp {
724− if i >= maxLSPs {
725− break
726− }
727−
728− iconColor := t.Success
729− if l.LSP.Disabled {
730− iconColor = t.FgMuted
731− }
732− lspErrs := map[protocol.DiagnosticSeverity]int{
733− protocol.SeverityError: 0,
734− protocol.SeverityWarning: 0,
735− protocol.SeverityHint: 0,
736− protocol.SeverityInformation: 0,
737− }
738− if client, ok := m.lspClients[l.Name]; ok {
739− for _, diagnostics := range client.GetDiagnostics() {
740− for _, diagnostic := range diagnostics {
741− if severity, ok := lspErrs[diagnostic.Severity]; ok {
742− lspErrs[diagnostic.Severity] = severity + 1
743− }
744− }
745− }
746− }
747−
748− errs := []string{}
749− if lspErrs[protocol.SeverityError] > 0 {
750− errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
751− }
752− if lspErrs[protocol.SeverityWarning] > 0 {
753− errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
754− }
755− if lspErrs[protocol.SeverityHint] > 0 {
756− errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
757− }
758− if lspErrs[protocol.SeverityInformation] > 0 {
759− errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
760− }
761−
762− lspList = append(lspList,
763− core.Status(
764− core.StatusOpts{
765− IconColor: iconColor,
766− Title: l.Name,
767− Description: l.LSP.Command,
768− ExtraContent: strings.Join(errs, " "),
769− },
770− m.getMaxWidth(),
771− ),
772− )
773− }
774−
775− // Add indicator if there are more LSPs
776− if len(lsp) > maxLSPs {
777− remaining := len(lsp) - maxLSPs
778− lspList = append(lspList,
779− t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
780− )
781− }
782−
783− return lipgloss.JoinVertical(
784− lipgloss.Left,
785− lspList...,
786− )
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)
787489}
788490
789491func (m *sidebarCmp) mcpBlock() string {
790− t := styles.CurrentTheme()
791−
792− section := t.S().Subtle.Render(
793− core.Section("MCPs", m.getMaxWidth()),
794− )
795−
796− mcpList := []string{section, ""}
797−
798− mcps := config.Get().MCP.Sorted()
799− if len(mcps) == 0 {
800− return lipgloss.JoinVertical(
801− lipgloss.Left,
802− section,
803− "",
804− t.S().Base.Foreground(t.Border).Render("None"),
805− )
806− }
807−
808492 // Limit the number of MCPs shown
809493 _, _, maxMCPs := m.getDynamicLimits()
494+ mcps := config.Get().MCP.Sorted()
810495 maxMCPs = min(len(mcps), maxMCPs)
811− for i, l := range mcps {
812− if i >= maxMCPs {
813− break
814− }
815−
816− iconColor := t.Success
817− if l.MCP.Disabled {
818− iconColor = t.FgMuted
819− }
820− mcpList = append(mcpList,
821− core.Status(
822− core.StatusOpts{
823− IconColor: iconColor,
824− Title: l.Name,
825− Description: l.MCP.Command,
826− },
827− m.getMaxWidth(),
828− ),
829− )
830− }
831−
832− // Add indicator if there are more MCPs
833− if len(mcps) > maxMCPs {
834− remaining := len(mcps) - maxMCPs
835− mcpList = append(mcpList,
836− t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
837− )
838− }
839496
840− return lipgloss.JoinVertical(
841− lipgloss.Left,
842− mcpList...,
843− )
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)
844503}
845504
846505func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {