diff --git a/internal/tui/components/chat/header/header.go b/internal/tui/components/chat/header/header.go new file mode 100644 index 0000000000000000000000000000000000000000..c88ef849c87310fdf2327172399e5e0fa79d1528 --- /dev/null +++ b/internal/tui/components/chat/header/header.go @@ -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 +} diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go index 308e981214940ab8c409898dac8db2a42530b0e9..19befb2ba16c68624b04a8c101521ef9cda6c9fd 100644 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ b/internal/tui/components/chat/sidebar/sidebar.go @@ -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, diff --git a/internal/tui/components/core/helpers.go b/internal/tui/components/core/helpers.go index 8ec0125a280b1f0dae9b7cf94e11b4187a9c0539..bd14febf1c94577fbd4882248d1344f364464b46 100644 --- a/internal/tui/components/core/helpers.go +++ b/internal/tui/components/core/helpers.go @@ -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) diff --git a/internal/tui/components/core/layout/container.go b/internal/tui/components/core/layout/container.go index 02ae5c68d5aa885f56fb598127b358702a41e4c0..12148f9a5b134acbd9e015ec488ede573fb0a6ef 100644 --- a/internal/tui/components/core/layout/container.go +++ b/internal/tui/components/core/layout/container.go @@ -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 diff --git a/internal/tui/components/core/layout/layout.go b/internal/tui/components/core/layout/layout.go index 6af2d5fa87776716a2d96446cd060adccbe83654..f5f2361d72d0d41bcb898c81f00df174571cfa72 100644 --- a/internal/tui/components/core/layout/layout.go +++ b/internal/tui/components/core/layout/layout.go @@ -20,7 +20,7 @@ type Help interface { Bindings() []key.Binding } -type Positionable interface { +type Positional interface { SetPosition(x, y int) tea.Cmd } diff --git a/internal/tui/components/core/layout/split.go b/internal/tui/components/core/layout/split.go index 9af08f48d47c4d3a1e2283fabf9d1634b291da35..5309091a96c8ba29d31f5432b3a761e588e698d4 100644 --- a/internal/tui/components/core/layout/split.go +++ b/internal/tui/components/core/layout/split.go @@ -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, diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go index 67f591b6bf3971826d8dffb0f8cc63d1bf1e57fd..bce04a20eca8cecb8dddd14cddd596a897ae1670 100644 --- a/internal/tui/components/dialogs/commands/commands.go +++ b/internal/tui/components/dialogs/commands/commands.go @@ -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{ { diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 179147cbb3b3b9025957a60b04b5fa557d9136c3..1f0d5550b4c2dfd56b3b8152aca50d3a3ce1a505 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -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), } } diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go index bb3a11aa554062964d81bf37bca00c6f1220d8ca..8d3c014048b950a034357f10f0e7f6ce7883d2f0 100644 --- a/internal/tui/styles/theme.go +++ b/internal/tui/styles/theme.go @@ -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 {