sidebar.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613
  1. package sidebar
  2. import (
  3. "context"
  4. "fmt"
  5. "slices"
  6. "strings"
  7. tea "github.com/charmbracelet/bubbletea/v2"
  8. "github.com/charmbracelet/catwalk/pkg/catwalk"
  9. "github.com/charmbracelet/crush/internal/config"
  10. "github.com/charmbracelet/crush/internal/csync"
  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/home"
  15. "github.com/charmbracelet/crush/internal/lsp"
  16. "github.com/charmbracelet/crush/internal/pubsub"
  17. "github.com/charmbracelet/crush/internal/session"
  18. "github.com/charmbracelet/crush/internal/tui/components/chat"
  19. "github.com/charmbracelet/crush/internal/tui/components/core"
  20. "github.com/charmbracelet/crush/internal/tui/components/core/layout"
  21. "github.com/charmbracelet/crush/internal/tui/components/files"
  22. "github.com/charmbracelet/crush/internal/tui/components/logo"
  23. lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
  24. "github.com/charmbracelet/crush/internal/tui/components/mcp"
  25. "github.com/charmbracelet/crush/internal/tui/styles"
  26. "github.com/charmbracelet/crush/internal/tui/util"
  27. "github.com/charmbracelet/crush/internal/version"
  28. "github.com/charmbracelet/lipgloss/v2"
  29. "golang.org/x/text/cases"
  30. "golang.org/x/text/language"
  31. )
  32. type FileHistory struct {
  33. initialVersion history.File
  34. latestVersion history.File
  35. }
  36. const LogoHeightBreakpoint = 30
  37. // Default maximum number of items to show in each section
  38. const (
  39. DefaultMaxFilesShown = 10
  40. DefaultMaxLSPsShown = 8
  41. DefaultMaxMCPsShown = 8
  42. MinItemsPerSection = 2 // Minimum items to show per section
  43. )
  44. type SessionFile struct {
  45. History FileHistory
  46. FilePath string
  47. Additions int
  48. Deletions int
  49. }
  50. type SessionFilesMsg struct {
  51. Files []SessionFile
  52. }
  53. type Sidebar interface {
  54. util.Model
  55. layout.Sizeable
  56. SetSession(session session.Session) tea.Cmd
  57. SetCompactMode(bool)
  58. }
  59. type sidebarCmp struct {
  60. width, height int
  61. session session.Session
  62. logo string
  63. cwd string
  64. lspClients *csync.Map[string, *lsp.Client]
  65. compactMode bool
  66. history history.Service
  67. files *csync.Map[string, SessionFile]
  68. }
  69. func New(history history.Service, lspClients *csync.Map[string, *lsp.Client], compact bool) Sidebar {
  70. return &sidebarCmp{
  71. lspClients: lspClients,
  72. history: history,
  73. compactMode: compact,
  74. files: csync.NewMap[string, SessionFile](),
  75. }
  76. }
  77. func (m *sidebarCmp) Init() tea.Cmd {
  78. return nil
  79. }
  80. func (m *sidebarCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
  81. switch msg := msg.(type) {
  82. case SessionFilesMsg:
  83. m.files = csync.NewMap[string, SessionFile]()
  84. for _, file := range msg.Files {
  85. m.files.Set(file.FilePath, file)
  86. }
  87. return m, nil
  88. case chat.SessionClearedMsg:
  89. m.session = session.Session{}
  90. case pubsub.Event[history.File]:
  91. return m, m.handleFileHistoryEvent(msg)
  92. case pubsub.Event[session.Session]:
  93. if msg.Type == pubsub.UpdatedEvent {
  94. if m.session.ID == msg.Payload.ID {
  95. m.session = msg.Payload
  96. }
  97. }
  98. }
  99. return m, nil
  100. }
  101. func (m *sidebarCmp) View() string {
  102. t := styles.CurrentTheme()
  103. parts := []string{}
  104. style := t.S().Base.
  105. Width(m.width).
  106. Height(m.height).
  107. Padding(1)
  108. if m.compactMode {
  109. style = style.PaddingTop(0)
  110. }
  111. if !m.compactMode {
  112. if m.height > LogoHeightBreakpoint {
  113. parts = append(parts, m.logo)
  114. } else {
  115. // Use a smaller logo for smaller screens
  116. parts = append(parts,
  117. logo.SmallRender(m.width-style.GetHorizontalFrameSize()),
  118. "")
  119. }
  120. }
  121. if !m.compactMode && m.session.ID != "" {
  122. parts = append(parts, t.S().Muted.Render(m.session.Title), "")
  123. } else if m.session.ID != "" {
  124. parts = append(parts, t.S().Text.Render(m.session.Title), "")
  125. }
  126. if !m.compactMode {
  127. parts = append(parts,
  128. m.cwd,
  129. "",
  130. )
  131. }
  132. parts = append(parts,
  133. m.currentModelBlock(),
  134. )
  135. // Check if we should use horizontal layout for sections
  136. if m.compactMode && m.width > m.height {
  137. // Horizontal layout for compact mode when width > height
  138. sectionsContent := m.renderSectionsHorizontal()
  139. if sectionsContent != "" {
  140. parts = append(parts, "", sectionsContent)
  141. }
  142. } else {
  143. // Vertical layout (default)
  144. if m.session.ID != "" {
  145. parts = append(parts, "", m.filesBlock())
  146. }
  147. parts = append(parts,
  148. "",
  149. m.lspBlock(),
  150. "",
  151. m.mcpBlock(),
  152. )
  153. }
  154. return style.Render(
  155. lipgloss.JoinVertical(lipgloss.Left, parts...),
  156. )
  157. }
  158. func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) tea.Cmd {
  159. return func() tea.Msg {
  160. file := event.Payload
  161. found := false
  162. for existing := range m.files.Seq() {
  163. if existing.FilePath != file.Path {
  164. continue
  165. }
  166. if existing.History.latestVersion.Version < file.Version {
  167. existing.History.latestVersion = file
  168. } else if file.Version == 0 {
  169. existing.History.initialVersion = file
  170. } else {
  171. // If the version is not greater than the latest, we ignore it
  172. continue
  173. }
  174. before, _ := fsext.ToUnixLineEndings(existing.History.initialVersion.Content)
  175. after, _ := fsext.ToUnixLineEndings(existing.History.latestVersion.Content)
  176. path := existing.History.initialVersion.Path
  177. cwd := config.Get().WorkingDir()
  178. path = strings.TrimPrefix(path, cwd)
  179. _, additions, deletions := diff.GenerateDiff(before, after, path)
  180. existing.Additions = additions
  181. existing.Deletions = deletions
  182. m.files.Set(file.Path, existing)
  183. found = true
  184. break
  185. }
  186. if found {
  187. return nil
  188. }
  189. sf := SessionFile{
  190. History: FileHistory{
  191. initialVersion: file,
  192. latestVersion: file,
  193. },
  194. FilePath: file.Path,
  195. Additions: 0,
  196. Deletions: 0,
  197. }
  198. m.files.Set(file.Path, sf)
  199. return nil
  200. }
  201. }
  202. func (m *sidebarCmp) loadSessionFiles() tea.Msg {
  203. files, err := m.history.ListBySession(context.Background(), m.session.ID)
  204. if err != nil {
  205. return util.InfoMsg{
  206. Type: util.InfoTypeError,
  207. Msg: err.Error(),
  208. }
  209. }
  210. fileMap := make(map[string]FileHistory)
  211. for _, file := range files {
  212. if existing, ok := fileMap[file.Path]; ok {
  213. // Update the latest version
  214. existing.latestVersion = file
  215. fileMap[file.Path] = existing
  216. } else {
  217. // Add the initial version
  218. fileMap[file.Path] = FileHistory{
  219. initialVersion: file,
  220. latestVersion: file,
  221. }
  222. }
  223. }
  224. sessionFiles := make([]SessionFile, 0, len(fileMap))
  225. for path, fh := range fileMap {
  226. cwd := config.Get().WorkingDir()
  227. path = strings.TrimPrefix(path, cwd)
  228. before, _ := fsext.ToUnixLineEndings(fh.initialVersion.Content)
  229. after, _ := fsext.ToUnixLineEndings(fh.latestVersion.Content)
  230. _, additions, deletions := diff.GenerateDiff(before, after, path)
  231. sessionFiles = append(sessionFiles, SessionFile{
  232. History: fh,
  233. FilePath: path,
  234. Additions: additions,
  235. Deletions: deletions,
  236. })
  237. }
  238. return SessionFilesMsg{
  239. Files: sessionFiles,
  240. }
  241. }
  242. func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
  243. m.logo = m.logoBlock()
  244. m.cwd = cwd()
  245. m.width = width
  246. m.height = height
  247. return nil
  248. }
  249. func (m *sidebarCmp) GetSize() (int, int) {
  250. return m.width, m.height
  251. }
  252. func (m *sidebarCmp) logoBlock() string {
  253. t := styles.CurrentTheme()
  254. return logo.Render(version.Version, true, logo.Opts{
  255. FieldColor: t.Primary,
  256. TitleColorA: t.Secondary,
  257. TitleColorB: t.Primary,
  258. CharmColor: t.Secondary,
  259. VersionColor: t.Primary,
  260. Width: m.width - 2,
  261. })
  262. }
  263. func (m *sidebarCmp) getMaxWidth() int {
  264. return min(m.width-2, 58) // -2 for padding
  265. }
  266. // calculateAvailableHeight estimates how much height is available for dynamic content
  267. func (m *sidebarCmp) calculateAvailableHeight() int {
  268. usedHeight := 0
  269. if !m.compactMode {
  270. if m.height > LogoHeightBreakpoint {
  271. usedHeight += 7 // Approximate logo height
  272. } else {
  273. usedHeight += 2 // Smaller logo height
  274. }
  275. usedHeight += 1 // Empty line after logo
  276. }
  277. if m.session.ID != "" {
  278. usedHeight += 1 // Title line
  279. usedHeight += 1 // Empty line after title
  280. }
  281. if !m.compactMode {
  282. usedHeight += 1 // CWD line
  283. usedHeight += 1 // Empty line after CWD
  284. }
  285. usedHeight += 2 // Model info
  286. usedHeight += 6 // 3 sections × 2 lines each (header + empty line)
  287. // Base padding
  288. usedHeight += 2 // Top and bottom padding
  289. return max(0, m.height-usedHeight)
  290. }
  291. // getDynamicLimits calculates how many items to show in each section based on available height
  292. func (m *sidebarCmp) getDynamicLimits() (maxFiles, maxLSPs, maxMCPs int) {
  293. availableHeight := m.calculateAvailableHeight()
  294. // If we have very little space, use minimum values
  295. if availableHeight < 10 {
  296. return MinItemsPerSection, MinItemsPerSection, MinItemsPerSection
  297. }
  298. // Distribute available height among the three sections
  299. // Give priority to files, then LSPs, then MCPs
  300. totalSections := 3
  301. heightPerSection := availableHeight / totalSections
  302. // Calculate limits for each section, ensuring minimums
  303. maxFiles = max(MinItemsPerSection, min(DefaultMaxFilesShown, heightPerSection))
  304. maxLSPs = max(MinItemsPerSection, min(DefaultMaxLSPsShown, heightPerSection))
  305. maxMCPs = max(MinItemsPerSection, min(DefaultMaxMCPsShown, heightPerSection))
  306. // If we have extra space, give it to files first
  307. remainingHeight := availableHeight - (maxFiles + maxLSPs + maxMCPs)
  308. if remainingHeight > 0 {
  309. extraForFiles := min(remainingHeight, DefaultMaxFilesShown-maxFiles)
  310. maxFiles += extraForFiles
  311. remainingHeight -= extraForFiles
  312. if remainingHeight > 0 {
  313. extraForLSPs := min(remainingHeight, DefaultMaxLSPsShown-maxLSPs)
  314. maxLSPs += extraForLSPs
  315. remainingHeight -= extraForLSPs
  316. if remainingHeight > 0 {
  317. maxMCPs += min(remainingHeight, DefaultMaxMCPsShown-maxMCPs)
  318. }
  319. }
  320. }
  321. return maxFiles, maxLSPs, maxMCPs
  322. }
  323. // renderSectionsHorizontal renders the files, LSPs, and MCPs sections horizontally
  324. func (m *sidebarCmp) renderSectionsHorizontal() string {
  325. // Calculate available width for each section
  326. totalWidth := m.width - 4 // Account for padding and spacing
  327. sectionWidth := min(50, totalWidth/3)
  328. // Get the sections content with limited height
  329. var filesContent, lspContent, mcpContent string
  330. filesContent = m.filesBlockCompact(sectionWidth)
  331. lspContent = m.lspBlockCompact(sectionWidth)
  332. mcpContent = m.mcpBlockCompact(sectionWidth)
  333. return lipgloss.JoinHorizontal(lipgloss.Top, filesContent, " ", lspContent, " ", mcpContent)
  334. }
  335. // filesBlockCompact renders the files block with limited width and height for horizontal layout
  336. func (m *sidebarCmp) filesBlockCompact(maxWidth int) string {
  337. // Convert map to slice and handle type conversion
  338. sessionFiles := slices.Collect(m.files.Seq())
  339. fileSlice := make([]files.SessionFile, len(sessionFiles))
  340. for i, sf := range sessionFiles {
  341. fileSlice[i] = files.SessionFile{
  342. History: files.FileHistory{
  343. InitialVersion: sf.History.initialVersion,
  344. LatestVersion: sf.History.latestVersion,
  345. },
  346. FilePath: sf.FilePath,
  347. Additions: sf.Additions,
  348. Deletions: sf.Deletions,
  349. }
  350. }
  351. // Limit items for horizontal layout
  352. maxItems := min(5, len(fileSlice))
  353. availableHeight := m.height - 8 // Reserve space for header and other content
  354. if availableHeight > 0 {
  355. maxItems = min(maxItems, availableHeight)
  356. }
  357. return files.RenderFileBlock(fileSlice, files.RenderOptions{
  358. MaxWidth: maxWidth,
  359. MaxItems: maxItems,
  360. ShowSection: true,
  361. SectionName: "Modified Files",
  362. }, true)
  363. }
  364. // lspBlockCompact renders the LSP block with limited width and height for horizontal layout
  365. func (m *sidebarCmp) lspBlockCompact(maxWidth int) string {
  366. // Limit items for horizontal layout
  367. lspConfigs := config.Get().LSP.Sorted()
  368. maxItems := min(5, len(lspConfigs))
  369. availableHeight := m.height - 8
  370. if availableHeight > 0 {
  371. maxItems = min(maxItems, availableHeight)
  372. }
  373. return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
  374. MaxWidth: maxWidth,
  375. MaxItems: maxItems,
  376. ShowSection: true,
  377. SectionName: "LSPs",
  378. }, true)
  379. }
  380. // mcpBlockCompact renders the MCP block with limited width and height for horizontal layout
  381. func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
  382. // Limit items for horizontal layout
  383. maxItems := min(5, len(config.Get().MCP.Sorted()))
  384. availableHeight := m.height - 8
  385. if availableHeight > 0 {
  386. maxItems = min(maxItems, availableHeight)
  387. }
  388. return mcp.RenderMCPBlock(mcp.RenderOptions{
  389. MaxWidth: maxWidth,
  390. MaxItems: maxItems,
  391. ShowSection: true,
  392. SectionName: "MCPs",
  393. }, true)
  394. }
  395. func (m *sidebarCmp) filesBlock() string {
  396. // Convert map to slice and handle type conversion
  397. sessionFiles := slices.Collect(m.files.Seq())
  398. fileSlice := make([]files.SessionFile, len(sessionFiles))
  399. for i, sf := range sessionFiles {
  400. fileSlice[i] = files.SessionFile{
  401. History: files.FileHistory{
  402. InitialVersion: sf.History.initialVersion,
  403. LatestVersion: sf.History.latestVersion,
  404. },
  405. FilePath: sf.FilePath,
  406. Additions: sf.Additions,
  407. Deletions: sf.Deletions,
  408. }
  409. }
  410. // Limit the number of files shown
  411. maxFiles, _, _ := m.getDynamicLimits()
  412. maxFiles = min(len(fileSlice), maxFiles)
  413. return files.RenderFileBlock(fileSlice, files.RenderOptions{
  414. MaxWidth: m.getMaxWidth(),
  415. MaxItems: maxFiles,
  416. ShowSection: true,
  417. SectionName: core.Section("Modified Files", m.getMaxWidth()),
  418. }, true)
  419. }
  420. func (m *sidebarCmp) lspBlock() string {
  421. // Limit the number of LSPs shown
  422. _, maxLSPs, _ := m.getDynamicLimits()
  423. lspConfigs := config.Get().LSP.Sorted()
  424. maxLSPs = min(len(lspConfigs), maxLSPs)
  425. return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
  426. MaxWidth: m.getMaxWidth(),
  427. MaxItems: maxLSPs,
  428. ShowSection: true,
  429. SectionName: core.Section("LSPs", m.getMaxWidth()),
  430. }, true)
  431. }
  432. func (m *sidebarCmp) mcpBlock() string {
  433. // Limit the number of MCPs shown
  434. _, _, maxMCPs := m.getDynamicLimits()
  435. mcps := config.Get().MCP.Sorted()
  436. maxMCPs = min(len(mcps), maxMCPs)
  437. return mcp.RenderMCPBlock(mcp.RenderOptions{
  438. MaxWidth: m.getMaxWidth(),
  439. MaxItems: maxMCPs,
  440. ShowSection: true,
  441. SectionName: core.Section("MCPs", m.getMaxWidth()),
  442. }, true)
  443. }
  444. func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
  445. t := styles.CurrentTheme()
  446. // Format tokens in human-readable format (e.g., 110K, 1.2M)
  447. var formattedTokens string
  448. switch {
  449. case tokens >= 1_000_000:
  450. formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
  451. case tokens >= 1_000:
  452. formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
  453. default:
  454. formattedTokens = fmt.Sprintf("%d", tokens)
  455. }
  456. // Remove .0 suffix if present
  457. if strings.HasSuffix(formattedTokens, ".0K") {
  458. formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
  459. }
  460. if strings.HasSuffix(formattedTokens, ".0M") {
  461. formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
  462. }
  463. percentage := (float64(tokens) / float64(contextWindow)) * 100
  464. baseStyle := t.S().Base
  465. formattedCost := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("$%.2f", cost))
  466. formattedTokens = baseStyle.Foreground(t.FgSubtle).Render(fmt.Sprintf("(%s)", formattedTokens))
  467. formattedPercentage := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("%d%%", int(percentage)))
  468. formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
  469. if percentage > 80 {
  470. // add the warning icon
  471. formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
  472. }
  473. return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
  474. }
  475. func (s *sidebarCmp) currentModelBlock() string {
  476. cfg := config.Get()
  477. agentCfg := cfg.Agents[config.AgentCoder]
  478. selectedModel := cfg.Models[agentCfg.Model]
  479. model := config.Get().GetModelByType(agentCfg.Model)
  480. modelProvider := config.Get().GetProviderForModel(agentCfg.Model)
  481. t := styles.CurrentTheme()
  482. modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
  483. modelName := t.S().Text.Render(model.Name)
  484. modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
  485. parts := []string{
  486. modelInfo,
  487. }
  488. if model.CanReason {
  489. reasoningInfoStyle := t.S().Subtle.PaddingLeft(2)
  490. switch modelProvider.Type {
  491. case catwalk.TypeAnthropic:
  492. formatter := cases.Title(language.English, cases.NoLower)
  493. if selectedModel.Think {
  494. parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking on")))
  495. } else {
  496. parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking off")))
  497. }
  498. default:
  499. reasoningEffort := model.DefaultReasoningEffort
  500. if selectedModel.ReasoningEffort != "" {
  501. reasoningEffort = selectedModel.ReasoningEffort
  502. }
  503. formatter := cases.Title(language.English, cases.NoLower)
  504. parts = append(parts, reasoningInfoStyle.Render(formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))))
  505. }
  506. }
  507. if s.session.ID != "" {
  508. parts = append(
  509. parts,
  510. " "+formatTokensAndCost(
  511. s.session.CompletionTokens+s.session.PromptTokens,
  512. model.ContextWindow,
  513. s.session.Cost,
  514. ),
  515. )
  516. }
  517. return lipgloss.JoinVertical(
  518. lipgloss.Left,
  519. parts...,
  520. )
  521. }
  522. // SetSession implements Sidebar.
  523. func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd {
  524. m.session = session
  525. return m.loadSessionFiles
  526. }
  527. // SetCompactMode sets the compact mode for the sidebar.
  528. func (m *sidebarCmp) SetCompactMode(compact bool) {
  529. m.compactMode = compact
  530. }
  531. func cwd() string {
  532. cwd := config.Get().WorkingDir()
  533. t := styles.CurrentTheme()
  534. return t.S().Muted.Render(home.Short(cwd))
  535. }