1package sidebar
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "strings"
8
9 tea "github.com/charmbracelet/bubbletea/v2"
10 "github.com/charmbracelet/crush/internal/config"
11 "github.com/charmbracelet/crush/internal/diff"
12 "github.com/charmbracelet/crush/internal/fsext"
13 "github.com/charmbracelet/crush/internal/history"
14 "github.com/charmbracelet/crush/internal/llm/models"
15 "github.com/charmbracelet/crush/internal/logging"
16 "github.com/charmbracelet/crush/internal/lsp"
17 "github.com/charmbracelet/crush/internal/lsp/protocol"
18 "github.com/charmbracelet/crush/internal/pubsub"
19 "github.com/charmbracelet/crush/internal/session"
20 "github.com/charmbracelet/crush/internal/tui/components/chat"
21 "github.com/charmbracelet/crush/internal/tui/components/core"
22 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
23 "github.com/charmbracelet/crush/internal/tui/components/logo"
24 "github.com/charmbracelet/crush/internal/tui/styles"
25 "github.com/charmbracelet/crush/internal/tui/util"
26 "github.com/charmbracelet/crush/internal/version"
27 "github.com/charmbracelet/lipgloss/v2"
28 "github.com/charmbracelet/x/ansi"
29)
30
31const (
32 logoBreakpoint = 65
33)
34
35type SessionFile struct {
36 FilePath string
37 Additions int
38 Deletions int
39}
40type SessionFilesMsg struct {
41 Files []SessionFile
42}
43
44type Sidebar interface {
45 util.Model
46 layout.Sizeable
47}
48
49type sidebarCmp struct {
50 width, height int
51 session session.Session
52 logo string
53 cwd string
54 lspClients map[string]*lsp.Client
55 history history.Service
56 files []SessionFile
57}
58
59func NewSidebarCmp(history history.Service, lspClients map[string]*lsp.Client) Sidebar {
60 return &sidebarCmp{
61 lspClients: lspClients,
62 history: history,
63 }
64}
65
66func (m *sidebarCmp) Init() tea.Cmd {
67 m.logo = m.logoBlock(false)
68 m.cwd = cwd()
69 return nil
70}
71
72func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
73 switch msg := msg.(type) {
74 case chat.SessionSelectedMsg:
75 if msg.ID != m.session.ID {
76 m.session = msg
77 }
78 return m, m.loadSessionFiles
79 case SessionFilesMsg:
80 m.files = msg.Files
81 logging.Info("Loaded session files", "count", len(m.files))
82 return m, nil
83
84 case chat.SessionClearedMsg:
85 m.session = session.Session{}
86 case pubsub.Event[session.Session]:
87 if msg.Type == pubsub.UpdatedEvent {
88 if m.session.ID == msg.Payload.ID {
89 m.session = msg.Payload
90 }
91 }
92 }
93 return m, nil
94}
95
96func (m *sidebarCmp) View() tea.View {
97 t := styles.CurrentTheme()
98 parts := []string{
99 m.logo,
100 }
101
102 if m.session.ID != "" {
103 parts = append(parts, t.S().Muted.Render(m.session.Title), "")
104 }
105
106 parts = append(parts,
107 m.cwd,
108 "",
109 m.currentModelBlock(),
110 "",
111 m.filesBlock(),
112 "",
113 m.lspBlock(),
114 "",
115 m.mcpBlock(),
116 )
117
118 return tea.NewView(
119 lipgloss.JoinVertical(lipgloss.Left, parts...),
120 )
121}
122
123func (m *sidebarCmp) loadSessionFiles() tea.Msg {
124 files, err := m.history.ListBySession(context.Background(), m.session.ID)
125 if err != nil {
126 return util.InfoMsg{
127 Type: util.InfoTypeError,
128 Msg: err.Error(),
129 }
130 }
131
132 type fileHistory struct {
133 initialVersion history.File
134 latestVersion history.File
135 }
136
137 fileMap := make(map[string]fileHistory)
138
139 for _, file := range files {
140 if existing, ok := fileMap[file.Path]; ok {
141 // Update the latest version
142 if existing.latestVersion.CreatedAt < file.CreatedAt {
143 existing.latestVersion = file
144 }
145 if file.Version == history.InitialVersion {
146 existing.initialVersion = file
147 }
148 fileMap[file.Path] = existing
149 } else {
150 // Add the initial version
151 fileMap[file.Path] = fileHistory{
152 initialVersion: file,
153 latestVersion: file,
154 }
155 }
156 }
157
158 sessionFiles := make([]SessionFile, 0, len(fileMap))
159 for path, fh := range fileMap {
160 if fh.initialVersion.Version == history.InitialVersion {
161 _, additions, deletions := diff.GenerateDiff(fh.initialVersion.Content, fh.latestVersion.Content, fh.initialVersion.Path)
162 sessionFiles = append(sessionFiles, SessionFile{
163 FilePath: path,
164 Additions: additions,
165 Deletions: deletions,
166 })
167 }
168 }
169
170 return SessionFilesMsg{
171 Files: sessionFiles,
172 }
173}
174
175func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
176 if width < logoBreakpoint && m.width >= logoBreakpoint {
177 m.logo = m.logoBlock(true)
178 } else if width >= logoBreakpoint && m.width < logoBreakpoint {
179 m.logo = m.logoBlock(false)
180 }
181
182 m.width = width
183 m.height = height
184 return nil
185}
186
187func (m *sidebarCmp) GetSize() (int, int) {
188 return m.width, m.height
189}
190
191func (m *sidebarCmp) logoBlock(compact bool) string {
192 t := styles.CurrentTheme()
193 return logo.Render(version.Version, compact, logo.Opts{
194 FieldColor: t.Primary,
195 TitleColorA: t.Secondary,
196 TitleColorB: t.Primary,
197 CharmColor: t.Secondary,
198 VersionColor: t.Primary,
199 })
200}
201
202func (m *sidebarCmp) filesBlock() string {
203 maxWidth := min(m.width, 58)
204 t := styles.CurrentTheme()
205
206 section := t.S().Subtle.Render(
207 core.Section("Modified Files", maxWidth),
208 )
209
210 if len(m.files) == 0 {
211 return lipgloss.JoinVertical(
212 lipgloss.Left,
213 section,
214 "",
215 t.S().Base.Foreground(t.Border).Render("None"),
216 )
217 }
218
219 fileList := []string{section, ""}
220
221 for _, file := range m.files {
222 // Extract just the filename from the path
223
224 // Create status indicators for additions/deletions
225 var statusParts []string
226 if file.Additions > 0 {
227 statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
228 }
229 if file.Deletions > 0 {
230 statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
231 }
232
233 extraContent := strings.Join(statusParts, " ")
234 filePath := fsext.DirTrim(fsext.PrettyPath(file.FilePath), 2)
235 filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…")
236 fileList = append(fileList,
237 core.Status(
238 core.StatusOpts{
239 IconColor: t.FgMuted,
240 NoIcon: true,
241 Title: filePath,
242 ExtraContent: extraContent,
243 },
244 m.width,
245 ),
246 )
247 }
248
249 return lipgloss.JoinVertical(
250 lipgloss.Left,
251 fileList...,
252 )
253}
254
255func (m *sidebarCmp) lspBlock() string {
256 maxWidth := min(m.width, 58)
257 t := styles.CurrentTheme()
258
259 section := t.S().Subtle.Render(
260 core.Section("LSPs", maxWidth),
261 )
262
263 lspList := []string{section, ""}
264
265 lsp := config.Get().LSP
266 if len(lsp) == 0 {
267 return lipgloss.JoinVertical(
268 lipgloss.Left,
269 section,
270 "",
271 t.S().Base.Foreground(t.Border).Render("None"),
272 )
273 }
274
275 for n, l := range lsp {
276 iconColor := t.Success
277 if l.Disabled {
278 iconColor = t.FgMuted
279 }
280 lspErrs := map[protocol.DiagnosticSeverity]int{
281 protocol.SeverityError: 0,
282 protocol.SeverityWarning: 0,
283 protocol.SeverityHint: 0,
284 protocol.SeverityInformation: 0,
285 }
286 if client, ok := m.lspClients[n]; ok {
287 for _, diagnostics := range client.GetDiagnostics() {
288 for _, diagnostic := range diagnostics {
289 if severity, ok := lspErrs[diagnostic.Severity]; ok {
290 lspErrs[diagnostic.Severity] = severity + 1
291 }
292 }
293 }
294 }
295
296 errs := []string{}
297 if lspErrs[protocol.SeverityError] > 0 {
298 errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s%d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
299 }
300 if lspErrs[protocol.SeverityWarning] > 0 {
301 errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s%d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
302 }
303 if lspErrs[protocol.SeverityHint] > 0 {
304 errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s%d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
305 }
306 if lspErrs[protocol.SeverityInformation] > 0 {
307 errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s%d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
308 }
309
310 lspList = append(lspList,
311 core.Status(
312 core.StatusOpts{
313 IconColor: iconColor,
314 Title: n,
315 Description: l.Command,
316 ExtraContent: strings.Join(errs, " "),
317 },
318 m.width,
319 ),
320 )
321 }
322
323 return lipgloss.JoinVertical(
324 lipgloss.Left,
325 lspList...,
326 )
327}
328
329func (m *sidebarCmp) mcpBlock() string {
330 maxWidth := min(m.width, 58)
331 t := styles.CurrentTheme()
332
333 section := t.S().Subtle.Render(
334 core.Section("MCPs", maxWidth),
335 )
336
337 mcpList := []string{section, ""}
338
339 mcp := config.Get().MCPServers
340 if len(mcp) == 0 {
341 return lipgloss.JoinVertical(
342 lipgloss.Left,
343 section,
344 "",
345 t.S().Base.Foreground(t.Border).Render("None"),
346 )
347 }
348
349 for n, l := range mcp {
350 iconColor := t.Success
351 mcpList = append(mcpList,
352 core.Status(
353 core.StatusOpts{
354 IconColor: iconColor,
355 Title: n,
356 Description: l.Command,
357 },
358 m.width,
359 ),
360 )
361 }
362
363 return lipgloss.JoinVertical(
364 lipgloss.Left,
365 mcpList...,
366 )
367}
368
369func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
370 t := styles.CurrentTheme()
371 // Format tokens in human-readable format (e.g., 110K, 1.2M)
372 var formattedTokens string
373 switch {
374 case tokens >= 1_000_000:
375 formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
376 case tokens >= 1_000:
377 formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
378 default:
379 formattedTokens = fmt.Sprintf("%d", tokens)
380 }
381
382 // Remove .0 suffix if present
383 if strings.HasSuffix(formattedTokens, ".0K") {
384 formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
385 }
386 if strings.HasSuffix(formattedTokens, ".0M") {
387 formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
388 }
389
390 percentage := (float64(tokens) / float64(contextWindow)) * 100
391
392 baseStyle := t.S().Base
393
394 formattedCost := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("$%.2f", cost))
395
396 formattedTokens = baseStyle.Foreground(t.FgSubtle).Render(fmt.Sprintf("(%s)", formattedTokens))
397 formattedPercentage := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("%d%%", int(percentage)))
398 formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
399 if percentage > 80 {
400 // add the warning icon
401 formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
402 }
403
404 return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
405}
406
407func (s *sidebarCmp) currentModelBlock() string {
408 cfg := config.Get()
409 agentCfg := cfg.Agents[config.AgentCoder]
410 selectedModelID := agentCfg.Model
411 model := models.SupportedModels[selectedModelID]
412
413 t := styles.CurrentTheme()
414
415 modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
416 modelName := t.S().Text.Render(model.Name)
417 modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
418 parts := []string{
419 modelInfo,
420 }
421 if s.session.ID != "" {
422 parts = append(
423 parts,
424 " "+formatTokensAndCost(
425 s.session.CompletionTokens+s.session.PromptTokens,
426 model.ContextWindow,
427 s.session.Cost,
428 ),
429 )
430 }
431 return lipgloss.JoinVertical(
432 lipgloss.Left,
433 parts...,
434 )
435}
436
437func cwd() string {
438 cwd := config.WorkingDirectory()
439 t := styles.CurrentTheme()
440 // replace home directory with ~
441 homeDir, err := os.UserHomeDir()
442 if err == nil {
443 cwd = strings.ReplaceAll(cwd, homeDir, "~")
444 }
445 return t.S().Muted.Render(cwd)
446}