Detailed changes
@@ -0,0 +1,125 @@
+package header
+
+import (
+ "fmt"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/crush/internal/fsext"
+ "github.com/charmbracelet/crush/internal/llm/models"
+ "github.com/charmbracelet/crush/internal/lsp"
+ "github.com/charmbracelet/crush/internal/lsp/protocol"
+ "github.com/charmbracelet/crush/internal/session"
+ "github.com/charmbracelet/crush/internal/tui/styles"
+ "github.com/charmbracelet/crush/internal/tui/util"
+ "github.com/charmbracelet/lipgloss/v2"
+)
+
+type Header interface {
+ util.Model
+ SetSession(session session.Session)
+}
+
+type header struct {
+ width int
+ session session.Session
+ lspClients map[string]*lsp.Client
+ detailsOpen bool
+}
+
+func New(lspClients map[string]*lsp.Client) Header {
+ return &header{
+ lspClients: lspClients,
+ width: 0,
+ }
+}
+
+func (h *header) Init() tea.Cmd {
+ return nil
+}
+
+func (p *header) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ p.width = msg.Width - 2
+ }
+ return p, nil
+}
+
+func (p *header) View() tea.View {
+ if p.session.ID == "" {
+ return tea.NewView("")
+ }
+
+ t := styles.CurrentTheme()
+ details := p.details()
+ parts := []string{
+ t.S().Base.Foreground(t.Secondary).Render("Charmβ’"),
+ " ",
+ styles.ApplyBoldForegroundGrad("CRUSH", t.Secondary, t.Primary),
+ " ",
+ }
+
+ remainingWidth := p.width - lipgloss.Width(strings.Join(parts, "")) - lipgloss.Width(details) - 2
+ if remainingWidth > 0 {
+ char := "β±"
+ lines := strings.Repeat(char, remainingWidth)
+ parts = append(parts, t.S().Base.Foreground(t.Primary).Render(lines), " ")
+ }
+
+ parts = append(parts, details)
+
+ content := t.S().Base.Padding(0, 1).Render(
+ lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ parts...,
+ ),
+ )
+ return tea.NewView(content)
+}
+
+func (h *header) details() string {
+ t := styles.CurrentTheme()
+ cwd := fsext.DirTrim(fsext.PrettyPath(config.WorkingDirectory()), 4)
+ parts := []string{
+ t.S().Muted.Render(cwd),
+ }
+
+ errorCount := 0
+ for _, l := range h.lspClients {
+ for _, diagnostics := range l.GetDiagnostics() {
+ for _, diagnostic := range diagnostics {
+ if diagnostic.Severity == protocol.SeverityError {
+ errorCount++
+ }
+ }
+ }
+ }
+
+ if errorCount > 0 {
+ parts = append(parts, t.S().Error.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount)))
+ }
+
+ cfg := config.Get()
+ agentCfg := cfg.Agents[config.AgentCoder]
+ selectedModelID := agentCfg.Model
+ model := models.SupportedModels[selectedModelID]
+
+ percentage := (float64(h.session.CompletionTokens+h.session.PromptTokens) / float64(model.ContextWindow)) * 100
+ formattedPercentage := t.S().Muted.Render(fmt.Sprintf("%d%%", int(percentage)))
+ parts = append(parts, formattedPercentage)
+
+ if h.detailsOpen {
+ parts = append(parts, t.S().Muted.Render("ctrl+d")+t.S().Subtle.Render(" close"))
+ } else {
+ parts = append(parts, t.S().Muted.Render("ctrl+d")+t.S().Subtle.Render(" open"))
+ }
+ dot := t.S().Subtle.Render(" β’ ")
+ return strings.Join(parts, dot)
+}
+
+// SetSession implements Header.
+func (h *header) SetSession(session session.Session) {
+ h.session = session
+}
@@ -223,9 +223,9 @@ func (m *sidebarCmp) loadSessionFiles() tea.Msg {
}
func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
- if width < logoBreakpoint && m.width >= logoBreakpoint {
+ if width < logoBreakpoint && (m.width == 0 || m.width >= logoBreakpoint) {
m.logo = m.logoBlock(true)
- } else if width >= logoBreakpoint && m.width < logoBreakpoint {
+ } else if width >= logoBreakpoint && (m.width == 0 || m.width < logoBreakpoint) {
m.logo = m.logoBlock(false)
}
@@ -358,16 +358,16 @@ func (m *sidebarCmp) lspBlock() string {
errs := []string{}
if lspErrs[protocol.SeverityError] > 0 {
- errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s%d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
+ errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
}
if lspErrs[protocol.SeverityWarning] > 0 {
- errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s%d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
+ errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
}
if lspErrs[protocol.SeverityHint] > 0 {
- errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s%d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
+ errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
}
if lspErrs[protocol.SeverityInformation] > 0 {
- errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s%d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
+ errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
}
lspList = append(lspList,
@@ -72,11 +72,11 @@ func Status(ops StatusOpts, width int) string {
}
title = t.S().Base.Foreground(titleColor).Render(title)
if description != "" {
- extraContent := len(ops.ExtraContent)
- if extraContent > 0 {
- extraContent += 1
+ extraContentWidth := lipgloss.Width(ops.ExtraContent)
+ if extraContentWidth > 0 {
+ extraContentWidth += 1
}
- description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContent, "β¦")
+ description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContentWidth, "β¦")
}
description = t.S().Base.Foreground(descriptionColor).Render(description)
@@ -12,7 +12,7 @@ type Container interface {
util.Model
Sizeable
Help
- Positionable
+ Positional
Focusable
}
type container struct {
@@ -151,7 +151,7 @@ func (c *container) GetSize() (int, int) {
func (c *container) SetPosition(x, y int) tea.Cmd {
c.x = x
c.y = y
- if positionable, ok := c.content.(Positionable); ok {
+ if positionable, ok := c.content.(Positional); ok {
return positionable.SetPosition(x, y)
}
return nil
@@ -20,7 +20,7 @@ type Help interface {
Bindings() []key.Binding
}
-type Positionable interface {
+type Positional interface {
SetPosition(x, y int) tea.Cmd
}
@@ -3,6 +3,7 @@ package layout
import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/crush/internal/logging"
"github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/crush/internal/tui/util"
"github.com/charmbracelet/lipgloss/v2"
@@ -29,11 +30,14 @@ type SplitPaneLayout interface {
ClearBottomPanel() tea.Cmd
FocusPanel(panel LayoutPanel) tea.Cmd
+ SetOffset(x, y int)
}
type splitPaneLayout struct {
- width int
- height int
+ width int
+ height int
+ xOffset int
+ yOffset int
ratio float64
verticalRatio float64
@@ -150,6 +154,7 @@ func (s *splitPaneLayout) View() tea.View {
func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
s.width = width
s.height = height
+ logging.Info("Setting split pane size", "width", width, "height", height)
var topHeight, bottomHeight int
var cmds []tea.Cmd
@@ -194,24 +199,24 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
if s.leftPanel != nil {
cmd := s.leftPanel.SetSize(leftWidth, topHeight)
cmds = append(cmds, cmd)
- if positionable, ok := s.leftPanel.(Positionable); ok {
- cmds = append(cmds, positionable.SetPosition(0, 0))
+ if positional, ok := s.leftPanel.(Positional); ok {
+ cmds = append(cmds, positional.SetPosition(s.xOffset, s.yOffset))
}
}
if s.rightPanel != nil {
cmd := s.rightPanel.SetSize(rightWidth, topHeight)
cmds = append(cmds, cmd)
- if positionable, ok := s.rightPanel.(Positionable); ok {
- cmds = append(cmds, positionable.SetPosition(leftWidth, 0))
+ if positional, ok := s.rightPanel.(Positional); ok {
+ cmds = append(cmds, positional.SetPosition(s.xOffset+leftWidth, s.yOffset))
}
}
if s.bottomPanel != nil {
cmd := s.bottomPanel.SetSize(width, bottomHeight)
cmds = append(cmds, cmd)
- if positionable, ok := s.bottomPanel.(Positionable); ok {
- cmds = append(cmds, positionable.SetPosition(0, topHeight))
+ if positional, ok := s.bottomPanel.(Positional); ok {
+ cmds = append(cmds, positional.SetPosition(s.xOffset, s.yOffset+topHeight))
}
}
return tea.Batch(cmds...)
@@ -308,6 +313,12 @@ func (s *splitPaneLayout) FocusPanel(panel LayoutPanel) tea.Cmd {
return tea.Batch(cmds...)
}
+// SetOffset implements SplitPaneLayout.
+func (s *splitPaneLayout) SetOffset(x int, y int) {
+ s.xOffset = x
+ s.yOffset = y
+}
+
func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
layout := &splitPaneLayout{
ratio: 0.8,
@@ -54,9 +54,10 @@ type commandDialogCmp struct {
}
type (
- SwitchSessionsMsg struct{}
- SwitchModelMsg struct{}
- CompactMsg struct {
+ SwitchSessionsMsg struct{}
+ SwitchModelMsg struct{}
+ ToggleCompactModeMsg struct{}
+ CompactMsg struct {
SessionID string
}
)
@@ -111,6 +112,7 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
c.wWidth = msg.Width
c.wHeight = msg.Height
+ c.SetCommandType(c.commandType)
return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
case tea.KeyPressMsg:
switch {
@@ -251,8 +253,8 @@ func (c *commandDialogCmp) defaultCommands() []Command {
// Only show compact command if there's an active session
if c.sessionID != "" {
commands = append(commands, Command{
- ID: "compact",
- Title: "Compact Session",
+ ID: "Summarize",
+ Title: "Summarize Session",
Description: "Summarize the current session and create a new one with the summary",
Handler: func(cmd Command) tea.Cmd {
return util.CmdHandler(CompactMsg{
@@ -261,6 +263,17 @@ func (c *commandDialogCmp) defaultCommands() []Command {
},
})
}
+ // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
+ if c.wWidth > 90 && c.sessionID != "" {
+ commands = append(commands, Command{
+ ID: "toggle_sidebar",
+ Title: "Toggle Sidebar",
+ Description: "Toggle between compact and normal layout",
+ Handler: func(cmd Command) tea.Cmd {
+ return util.CmdHandler(ToggleCompactModeMsg{})
+ },
+ })
+ }
return append(commands, []Command{
{
@@ -2,6 +2,7 @@ package chat
import (
"context"
+ "strings"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
@@ -12,6 +13,7 @@ import (
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/tui/components/chat"
"github.com/charmbracelet/crush/internal/tui/components/chat/editor"
+ "github.com/charmbracelet/crush/internal/tui/components/chat/header"
"github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
@@ -21,6 +23,8 @@ import (
var ChatPageID page.PageID = "chat"
+const CompactModeBreakpoint = 90 // Width at which the chat page switches to compact mode
+
type (
OpenFilePickerMsg struct{}
ChatFocusedMsg struct {
@@ -34,7 +38,8 @@ type ChatPage interface {
}
type chatPage struct {
- app *app.App
+ wWidth, wHeight int // Window dimensions
+ app *app.App
layout layout.SplitPaneLayout
@@ -43,6 +48,10 @@ type chatPage struct {
keyMap KeyMap
chatFocused bool
+
+ compactMode bool
+ forceCompactMode bool // Force compact mode regardless of window size
+ header header.Header
}
func (p *chatPage) Init() tea.Cmd {
@@ -57,13 +66,55 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
- cmd := p.layout.SetSize(msg.Width, msg.Height)
+ h, cmd := p.header.Update(msg)
cmds = append(cmds, cmd)
+ p.header = h.(header.Header)
+ // the mode is only relevant when there is a session
+ if p.session.ID != "" {
+ // Only auto-switch to compact mode if not forced
+ if !p.forceCompactMode {
+ if msg.Width <= CompactModeBreakpoint && p.wWidth > CompactModeBreakpoint {
+ p.wWidth = msg.Width
+ p.wHeight = msg.Height
+ cmds = append(cmds, p.setCompactMode(true))
+ return p, tea.Batch(cmds...)
+ } else if msg.Width > CompactModeBreakpoint && p.wWidth <= CompactModeBreakpoint {
+ p.wWidth = msg.Width
+ p.wHeight = msg.Height
+ return p, p.setCompactMode(false)
+ }
+ }
+ }
+ p.wWidth = msg.Width
+ p.wHeight = msg.Height
+ layoutHeight := msg.Height
+ if p.compactMode {
+ // make space for the header
+ layoutHeight -= 1
+ }
+ cmd = p.layout.SetSize(msg.Width, layoutHeight)
+ cmds = append(cmds, cmd)
+ return p, tea.Batch(cmds...)
+
case chat.SendMsg:
cmd := p.sendMessage(msg.Text, msg.Attachments)
if cmd != nil {
return p, cmd
}
+ case commands.ToggleCompactModeMsg:
+ // Only allow toggling if window width is larger than compact breakpoint
+ if p.wWidth > CompactModeBreakpoint {
+ p.forceCompactMode = !p.forceCompactMode
+ // If force compact mode is enabled, switch to compact mode
+ // If force compact mode is disabled, switch based on window size
+ if p.forceCompactMode {
+ return p, p.setCompactMode(true)
+ } else {
+ // Return to auto mode based on window size
+ shouldBeCompact := p.wWidth <= CompactModeBreakpoint
+ return p, p.setCompactMode(shouldBeCompact)
+ }
+ }
case commands.CommandRunCustomMsg:
// Check if the agent is busy before executing custom commands
if p.app.CoderAgent.IsBusy() {
@@ -82,7 +133,12 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
}
}
+ needsModeChange := p.session.ID == ""
p.session = msg
+ p.header.SetSession(msg)
+ if needsModeChange && (p.wWidth <= CompactModeBreakpoint || p.forceCompactMode) {
+ cmds = append(cmds, p.setCompactMode(true))
+ }
case tea.KeyPressMsg:
switch {
case key.Matches(msg, p.keyMap.NewSession):
@@ -90,8 +146,8 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return p, tea.Batch(
p.clearMessages(),
util.CmdHandler(chat.SessionClearedMsg{}),
+ p.setCompactMode(false),
)
-
case key.Matches(msg, p.keyMap.AddAttachment):
cfg := config.Get()
agentCfg := cfg.Agents[config.AgentCoder]
@@ -128,6 +184,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
p.layout = u.(layout.SplitPaneLayout)
+ h, cmd := p.header.Update(msg)
+ cmds = append(cmds, cmd)
+ p.header = h.(header.Header)
return p, tea.Batch(cmds...)
}
@@ -139,10 +198,38 @@ func (p *chatPage) setMessages() tea.Cmd {
return tea.Batch(p.layout.SetLeftPanel(messagesContainer), messagesContainer.Init())
}
+func (p *chatPage) setSidebar() tea.Cmd {
+ sidebarContainer := sidebarCmp(p.app)
+ sidebarContainer.Init()
+ return p.layout.SetRightPanel(sidebarContainer)
+}
+
func (p *chatPage) clearMessages() tea.Cmd {
return p.layout.ClearLeftPanel()
}
+func (p *chatPage) setCompactMode(compact bool) tea.Cmd {
+ p.compactMode = compact
+ var cmds []tea.Cmd
+ if compact {
+ // add offset for the header
+ p.layout.SetOffset(0, 1)
+ // make space for the header
+ cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight-1))
+ // remove the sidebar
+ cmds = append(cmds, p.layout.ClearRightPanel())
+ return tea.Batch(cmds...)
+ } else {
+ // remove the offset for the header
+ p.layout.SetOffset(0, 0)
+ // restore the original size
+ cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight))
+ // set the sidebar
+ cmds = append(cmds, p.setSidebar())
+ return tea.Batch(cmds...)
+ }
+}
+
func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
var cmds []tea.Cmd
if p.session.ID == "" {
@@ -175,7 +262,21 @@ func (p *chatPage) GetSize() (int, int) {
}
func (p *chatPage) View() tea.View {
- return p.layout.View()
+ if !p.compactMode || p.session.ID == "" {
+ // If not in compact mode or there is no session, we don't show the header
+ return p.layout.View()
+ }
+ layoutView := p.layout.View()
+ chatView := tea.NewView(
+ strings.Join(
+ []string{
+ p.header.View().String(),
+ layoutView.String(),
+ }, "\n",
+ ),
+ )
+ chatView.SetCursor(layoutView.Cursor())
+ return chatView
}
func (p *chatPage) Bindings() []key.Binding {
@@ -207,22 +308,26 @@ func (p *chatPage) Bindings() []key.Binding {
return bindings
}
-func NewChatPage(app *app.App) ChatPage {
- sidebarContainer := layout.NewContainer(
+func sidebarCmp(app *app.App) layout.Container {
+ return layout.NewContainer(
sidebar.NewSidebarCmp(app.History, app.LSPClients),
layout.WithPadding(1, 1, 1, 1),
)
+}
+
+func NewChatPage(app *app.App) ChatPage {
editorContainer := layout.NewContainer(
editor.NewEditorCmp(app),
)
return &chatPage{
app: app,
layout: layout.NewSplitPane(
- layout.WithRightPanel(sidebarContainer),
+ layout.WithRightPanel(sidebarCmp(app)),
layout.WithBottomPanel(editorContainer),
layout.WithFixedBottomHeight(5),
layout.WithFixedRightWidth(31),
),
keyMap: DefaultKeyMap(),
+ header: header.New(app.LSPClients),
}
}
@@ -582,6 +582,33 @@ func ApplyForegroundGrad(input string, color1, color2 color.Color) string {
return o.String()
}
+// ApplyBoldForegroundGrad renders a given string with a horizontal gradient
+// foreground.
+func ApplyBoldForegroundGrad(input string, color1, color2 color.Color) string {
+ if input == "" {
+ return ""
+ }
+ t := CurrentTheme()
+
+ var o strings.Builder
+ if len(input) == 1 {
+ return t.S().Base.Bold(true).Foreground(color1).Render(input)
+ }
+
+ var clusters []string
+ gr := uniseg.NewGraphemes(input)
+ for gr.Next() {
+ clusters = append(clusters, string(gr.Runes()))
+ }
+
+ ramp := blendColors(len(clusters), color1, color2)
+ for i, c := range ramp {
+ fmt.Fprint(&o, t.S().Base.Bold(true).Foreground(c).Render(clusters[i]))
+ }
+
+ return o.String()
+}
+
// blendColors returns a slice of colors blended between the given keys.
// Blending is done in Hcl to stay in gamut.
func blendColors(size int, stops ...color.Color) []color.Color {