diff --git a/internal/fileutil/fileutil.go b/internal/fileutil/fileutil.go index 92fc9d39c585f7784c7fe8ca21a0cf8d6958cbcb..4955c0811d586a4035808d9813a35b3c7c2f8d10 100644 --- a/internal/fileutil/fileutil.go +++ b/internal/fileutil/fileutil.go @@ -211,3 +211,27 @@ func PrettyPath(path string) string { } return path } + +func DirTrim(pwd string, lim int) string { + var ( + out string + sep = string(filepath.Separator) + ) + dirs := strings.Split(pwd, sep) + if lim > len(dirs)-1 || lim <= 0 { + return pwd + } + for i := len(dirs) - 1; i > 0; i-- { + out = sep + out + if i == len(dirs)-1 { + out = dirs[i] + } else if i >= len(dirs)-lim { + out = string(dirs[i][0]) + out + } else { + out = "..." + out + break + } + } + out = filepath.Join("~", out) + return out +} diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go index 9ed5c9a28a4fe65f8b23338ea259e82e5e6ef2f9..b0726239a545f8dc890a0bcc1932110150242519 100644 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ b/internal/tui/components/chat/sidebar/sidebar.go @@ -1,12 +1,16 @@ package sidebar import ( + "context" "fmt" "os" "strings" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/diff" + "github.com/charmbracelet/crush/internal/fileutil" + "github.com/charmbracelet/crush/internal/history" "github.com/charmbracelet/crush/internal/llm/models" "github.com/charmbracelet/crush/internal/logging" "github.com/charmbracelet/crush/internal/lsp" @@ -21,12 +25,22 @@ import ( "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/crush/internal/version" "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/x/ansi" ) const ( logoBreakpoint = 65 ) +type SessionFile struct { + FilePath string + Additions int + Deletions int +} +type SessionFilesMsg struct { + Files []SessionFile +} + type Sidebar interface { util.Model layout.Sizeable @@ -38,11 +52,14 @@ type sidebarCmp struct { logo string cwd string lspClients map[string]*lsp.Client + history history.Service + files []SessionFile } -func NewSidebarCmp(lspClients map[string]*lsp.Client) Sidebar { +func NewSidebarCmp(history history.Service, lspClients map[string]*lsp.Client) Sidebar { return &sidebarCmp{ lspClients: lspClients, + history: history, } } @@ -58,6 +75,12 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.ID != m.session.ID { m.session = msg } + return m, m.loadSessionFiles + case SessionFilesMsg: + m.files = msg.Files + logging.Info("Loaded session files", "count", len(m.files)) + return m, nil + case chat.SessionClearedMsg: m.session = session.Session{} case pubsub.Event[session.Session]: @@ -85,6 +108,8 @@ func (m *sidebarCmp) View() tea.View { "", m.currentModelBlock(), "", + m.filesBlock(), + "", m.lspBlock(), "", m.mcpBlock(), @@ -95,6 +120,58 @@ func (m *sidebarCmp) View() tea.View { ) } +func (m *sidebarCmp) loadSessionFiles() tea.Msg { + files, err := m.history.ListBySession(context.Background(), m.session.ID) + if err != nil { + return util.InfoMsg{ + Type: util.InfoTypeError, + Msg: err.Error(), + } + } + + type fileHistory struct { + initialVersion history.File + latestVersion history.File + } + + fileMap := make(map[string]fileHistory) + + for _, file := range files { + if existing, ok := fileMap[file.Path]; ok { + // Update the latest version + if existing.latestVersion.CreatedAt < file.CreatedAt { + existing.latestVersion = file + } + if file.Version == history.InitialVersion { + existing.initialVersion = file + } + fileMap[file.Path] = existing + } else { + // Add the initial version + fileMap[file.Path] = fileHistory{ + initialVersion: file, + latestVersion: file, + } + } + } + + sessionFiles := make([]SessionFile, 0, len(fileMap)) + for path, fh := range fileMap { + if fh.initialVersion.Version == history.InitialVersion { + _, additions, deletions := diff.GenerateDiff(fh.initialVersion.Content, fh.latestVersion.Content, fh.initialVersion.Path) + sessionFiles = append(sessionFiles, SessionFile{ + FilePath: path, + Additions: additions, + Deletions: deletions, + }) + } + } + + return SessionFilesMsg{ + Files: sessionFiles, + } +} + func (m *sidebarCmp) SetSize(width, height int) tea.Cmd { if width < logoBreakpoint && m.width >= logoBreakpoint { m.logo = m.logoBlock(true) @@ -122,6 +199,59 @@ func (m *sidebarCmp) logoBlock(compact bool) string { }) } +func (m *sidebarCmp) filesBlock() string { + maxWidth := min(m.width, 58) + t := styles.CurrentTheme() + + section := t.S().Muted.Render( + core.Section("Files", maxWidth), + ) + + if len(m.files) == 0 { + return lipgloss.JoinVertical( + lipgloss.Left, + section, + "", + t.S().Base.Foreground(t.Border).Render("None"), + ) + } + + fileList := []string{section, ""} + + for _, file := range m.files { + // Extract just the filename from the path + + // Create status indicators for additions/deletions + var statusParts []string + if file.Additions > 0 { + statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions))) + } + if file.Deletions > 0 { + statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions))) + } + + extraContent := strings.Join(statusParts, " ") + filePath := fileutil.DirTrim(fileutil.PrettyPath(file.FilePath), 2) + filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…") + fileList = append(fileList, + core.Status( + core.StatusOpts{ + IconColor: t.FgMuted, + NoIcon: true, + Title: filePath, + ExtraContent: extraContent, + }, + m.width, + ), + ) + } + + return lipgloss.JoinVertical( + lipgloss.Left, + fileList..., + ) +} + func (m *sidebarCmp) lspBlock() string { maxWidth := min(m.width, 58) t := styles.CurrentTheme() @@ -177,7 +307,6 @@ func (m *sidebarCmp) lspBlock() string { 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{ diff --git a/internal/tui/components/core/helpers.go b/internal/tui/components/core/helpers.go index 3621956393a8b73f5f35f659ad9d72ebe77c53aa..3396b04f3eaff769c1dc47cd7292a5f3e337cd3b 100644 --- a/internal/tui/components/core/helpers.go +++ b/internal/tui/components/core/helpers.go @@ -38,6 +38,7 @@ func Title(title string, width int) string { type StatusOpts struct { Icon string IconColor color.Color + NoIcon bool // If true, no icon will be displayed Title string TitleColor color.Color Description string @@ -51,6 +52,8 @@ func Status(ops StatusOpts, width int) string { iconColor := t.Success if ops.Icon != "" { icon = ops.Icon + } else if ops.NoIcon { + icon = "" } if ops.IconColor != nil { iconColor = ops.IconColor @@ -65,7 +68,6 @@ func Status(ops StatusOpts, width int) string { if ops.DescriptionColor != nil { descriptionColor = ops.DescriptionColor } - icon = t.S().Base.Foreground(iconColor).Render(icon) title = t.S().Base.Foreground(titleColor).Render(title) if description != "" { extraContent := len(ops.ExtraContent) @@ -75,11 +77,12 @@ func Status(ops StatusOpts, width int) string { description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContent, "…") } description = t.S().Base.Foreground(descriptionColor).Render(description) - content := []string{ - icon, - title, - description, + + content := []string{} + if icon != "" { + content = append(content, t.S().Base.Foreground(iconColor).Render(icon)) } + content = append(content, title, description) if ops.ExtraContent != "" { content = append(content, ops.ExtraContent) } diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 94fa4dd353043aaf76dbd44f7253574d72f4927d..179147cbb3b3b9025957a60b04b5fa557d9136c3 100644 --- a/internal/tui/page/chat/chat.go +++ b/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(app.LSPClients), + sidebar.NewSidebarCmp(app.History, app.LSPClients), layout.WithPadding(1, 1, 1, 1), ) editorContainer := layout.NewContainer( diff --git a/todos.md b/todos.md index 9f03c059deecb22d86f42074050e4e7e15038174..50aa388e03352e05961308dea3b9b036f980f71c 100644 --- a/todos.md +++ b/todos.md @@ -27,6 +27,7 @@ - [ ] Update interactive mode to use the spinner - [ ] Revisit the core list component - [ ] This component has become super complex we might need to fix this. +- [ ] Handle correct LSP and MCP status icon - [ ] Investigate ways to make the spinner less CPU intensive - [ ] General cleanup and documentation - [ ] Update the readme