sidebar.go

  1package sidebar
  2
  3import (
  4	"fmt"
  5	"os"
  6	"strings"
  7
  8	tea "github.com/charmbracelet/bubbletea/v2"
  9	"github.com/charmbracelet/crush/internal/config"
 10	"github.com/charmbracelet/crush/internal/llm/models"
 11	"github.com/charmbracelet/crush/internal/logging"
 12	"github.com/charmbracelet/crush/internal/lsp"
 13	"github.com/charmbracelet/crush/internal/lsp/protocol"
 14	"github.com/charmbracelet/crush/internal/pubsub"
 15	"github.com/charmbracelet/crush/internal/session"
 16	"github.com/charmbracelet/crush/internal/tui/components/chat"
 17	"github.com/charmbracelet/crush/internal/tui/components/core"
 18	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 19	"github.com/charmbracelet/crush/internal/tui/components/logo"
 20	"github.com/charmbracelet/crush/internal/tui/styles"
 21	"github.com/charmbracelet/crush/internal/tui/util"
 22	"github.com/charmbracelet/crush/internal/version"
 23	"github.com/charmbracelet/lipgloss/v2"
 24)
 25
 26const (
 27	logoBreakpoint = 65
 28)
 29
 30type Sidebar interface {
 31	util.Model
 32	layout.Sizeable
 33}
 34
 35type sidebarCmp struct {
 36	width, height int
 37	session       session.Session
 38	logo          string
 39	cwd           string
 40	lspClients    map[string]*lsp.Client
 41}
 42
 43func NewSidebarCmp(lspClients map[string]*lsp.Client) Sidebar {
 44	return &sidebarCmp{
 45		lspClients: lspClients,
 46	}
 47}
 48
 49func (m *sidebarCmp) Init() tea.Cmd {
 50	m.logo = m.logoBlock(false)
 51	m.cwd = cwd()
 52	return nil
 53}
 54
 55func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 56	switch msg := msg.(type) {
 57	case chat.SessionSelectedMsg:
 58		if msg.ID != m.session.ID {
 59			m.session = msg
 60		}
 61	case chat.SessionClearedMsg:
 62		m.session = session.Session{}
 63	case pubsub.Event[session.Session]:
 64		if msg.Type == pubsub.UpdatedEvent {
 65			if m.session.ID == msg.Payload.ID {
 66				m.session = msg.Payload
 67			}
 68		}
 69	}
 70	return m, nil
 71}
 72
 73func (m *sidebarCmp) View() tea.View {
 74	t := styles.CurrentTheme()
 75	parts := []string{
 76		m.logo,
 77	}
 78
 79	if m.session.ID != "" {
 80		parts = append(parts, t.S().Muted.Render(m.session.Title), "")
 81	}
 82
 83	parts = append(parts,
 84		m.cwd,
 85		"",
 86		m.currentModelBlock(),
 87		"",
 88		m.lspBlock(),
 89		"",
 90		m.mcpBlock(),
 91	)
 92
 93	return tea.NewView(
 94		lipgloss.JoinVertical(lipgloss.Left, parts...),
 95	)
 96}
 97
 98func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
 99	if width < logoBreakpoint && m.width >= logoBreakpoint {
100		m.logo = m.logoBlock(true)
101	} else if width >= logoBreakpoint && m.width < logoBreakpoint {
102		m.logo = m.logoBlock(false)
103	}
104
105	m.width = width
106	m.height = height
107	return nil
108}
109
110func (m *sidebarCmp) GetSize() (int, int) {
111	return m.width, m.height
112}
113
114func (m *sidebarCmp) logoBlock(compact bool) string {
115	t := styles.CurrentTheme()
116	return logo.Render(version.Version, compact, logo.Opts{
117		FieldColor:   t.Primary,
118		TitleColorA:  t.Secondary,
119		TitleColorB:  t.Primary,
120		CharmColor:   t.Secondary,
121		VersionColor: t.Primary,
122	})
123}
124
125func (m *sidebarCmp) lspBlock() string {
126	maxWidth := min(m.width, 58)
127	t := styles.CurrentTheme()
128
129	section := t.S().Muted.Render(
130		core.Section("LSPs", maxWidth),
131	)
132
133	lspList := []string{section, ""}
134
135	lsp := config.Get().LSP
136	if len(lsp) == 0 {
137		return lipgloss.JoinVertical(
138			lipgloss.Left,
139			section,
140			"",
141			t.S().Base.Foreground(t.Border).Render("None"),
142		)
143	}
144
145	for n, l := range lsp {
146		iconColor := t.Success
147		if l.Disabled {
148			iconColor = t.FgMuted
149		}
150		lspErrs := map[protocol.DiagnosticSeverity]int{
151			protocol.SeverityError:       0,
152			protocol.SeverityWarning:     0,
153			protocol.SeverityHint:        0,
154			protocol.SeverityInformation: 0,
155		}
156		if client, ok := m.lspClients[n]; ok {
157			for _, diagnostics := range client.GetDiagnostics() {
158				for _, diagnostic := range diagnostics {
159					if severity, ok := lspErrs[diagnostic.Severity]; ok {
160						lspErrs[diagnostic.Severity] = severity + 1
161					}
162				}
163			}
164		}
165
166		errs := []string{}
167		if lspErrs[protocol.SeverityError] > 0 {
168			errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s%d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
169		}
170		if lspErrs[protocol.SeverityWarning] > 0 {
171			errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s%d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
172		}
173		if lspErrs[protocol.SeverityHint] > 0 {
174			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s%d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
175		}
176		if lspErrs[protocol.SeverityInformation] > 0 {
177			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s%d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
178		}
179
180		logging.Info("LSP Errors", "errors", errs)
181		lspList = append(lspList,
182			core.Status(
183				core.StatusOpts{
184					IconColor:    iconColor,
185					Title:        n,
186					Description:  l.Command,
187					ExtraContent: strings.Join(errs, " "),
188				},
189				m.width,
190			),
191		)
192	}
193
194	return lipgloss.JoinVertical(
195		lipgloss.Left,
196		lspList...,
197	)
198}
199
200func (m *sidebarCmp) mcpBlock() string {
201	maxWidth := min(m.width, 58)
202	t := styles.CurrentTheme()
203
204	section := t.S().Muted.Render(
205		core.Section("MCPs", maxWidth),
206	)
207
208	mcpList := []string{section, ""}
209
210	mcp := config.Get().MCPServers
211	if len(mcp) == 0 {
212		return lipgloss.JoinVertical(
213			lipgloss.Left,
214			section,
215			"",
216			t.S().Base.Foreground(t.Border).Render("None"),
217		)
218	}
219
220	for n, l := range mcp {
221		iconColor := t.Success
222		mcpList = append(mcpList,
223			core.Status(
224				core.StatusOpts{
225					IconColor:   iconColor,
226					Title:       n,
227					Description: l.Command,
228				},
229				m.width,
230			),
231		)
232	}
233
234	return lipgloss.JoinVertical(
235		lipgloss.Left,
236		mcpList...,
237	)
238}
239
240func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
241	t := styles.CurrentTheme()
242	// Format tokens in human-readable format (e.g., 110K, 1.2M)
243	var formattedTokens string
244	switch {
245	case tokens >= 1_000_000:
246		formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
247	case tokens >= 1_000:
248		formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
249	default:
250		formattedTokens = fmt.Sprintf("%d", tokens)
251	}
252
253	// Remove .0 suffix if present
254	if strings.HasSuffix(formattedTokens, ".0K") {
255		formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
256	}
257	if strings.HasSuffix(formattedTokens, ".0M") {
258		formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
259	}
260
261	percentage := (float64(tokens) / float64(contextWindow)) * 100
262
263	baseStyle := t.S().Base
264
265	formattedCost := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("$%.2f", cost))
266
267	formattedTokens = baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("(%s)", formattedTokens))
268	formattedPercentage := baseStyle.Foreground(t.FgSubtle).Render(fmt.Sprintf("%d%%", int(percentage)))
269	formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
270	if percentage > 80 {
271		// add the warning icon
272		formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
273	}
274
275	return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
276}
277
278func (s *sidebarCmp) currentModelBlock() string {
279	cfg := config.Get()
280	agentCfg := cfg.Agents[config.AgentCoder]
281	selectedModelID := agentCfg.Model
282	model := models.SupportedModels[selectedModelID]
283
284	t := styles.CurrentTheme()
285
286	modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
287	modelName := t.S().Text.Render(model.Name)
288	modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
289	parts := []string{
290		// section,
291		// "",
292		modelInfo,
293	}
294	if s.session.ID != "" {
295		parts = append(
296			parts,
297			"  "+formatTokensAndCost(
298				s.session.CompletionTokens+s.session.PromptTokens,
299				model.ContextWindow,
300				s.session.Cost,
301			),
302		)
303	}
304	return lipgloss.JoinVertical(
305		lipgloss.Left,
306		parts...,
307	)
308}
309
310func cwd() string {
311	cwd := config.WorkingDirectory()
312	t := styles.CurrentTheme()
313	// replace home directory with ~
314	homeDir, err := os.UserHomeDir()
315	if err == nil {
316		cwd = strings.ReplaceAll(cwd, homeDir, "~")
317	}
318	return t.S().Muted.Render(cwd)
319}