on main help sign in
feat: responsive layout
f19a809Kujtim Hoxha
internal/tui/components/chat/header/header.gonew file+125
@@ -0,0 +1,125 @@
1+package header
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ tea "github.com/charmbracelet/bubbletea/v2"
8+ "github.com/charmbracelet/crush/internal/config"
9+ "github.com/charmbracelet/crush/internal/fsext"
10+ "github.com/charmbracelet/crush/internal/llm/models"
11+ "github.com/charmbracelet/crush/internal/lsp"
12+ "github.com/charmbracelet/crush/internal/lsp/protocol"
13+ "github.com/charmbracelet/crush/internal/session"
14+ "github.com/charmbracelet/crush/internal/tui/styles"
15+ "github.com/charmbracelet/crush/internal/tui/util"
16+ "github.com/charmbracelet/lipgloss/v2"
17+)
18+
19+type Header interface {
20+ util.Model
21+ SetSession(session session.Session)
22+}
23+
24+type header struct {
25+ width int
26+ session session.Session
27+ lspClients map[string]*lsp.Client
28+ detailsOpen bool
29+}
30+
31+func New(lspClients map[string]*lsp.Client) Header {
32+ return &header{
33+ lspClients: lspClients,
34+ width: 0,
35+ }
36+}
37+
38+func (h *header) Init() tea.Cmd {
39+ return nil
40+}
41+
42+func (p *header) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
43+ switch msg := msg.(type) {
44+ case tea.WindowSizeMsg:
45+ p.width = msg.Width - 2
46+ }
47+ return p, nil
48+}
49+
50+func (p *header) View() tea.View {
51+ if p.session.ID == "" {
52+ return tea.NewView("")
53+ }
54+
55+ t := styles.CurrentTheme()
56+ details := p.details()
57+ parts := []string{
58+ t.S().Base.Foreground(t.Secondary).Render("Charm™"),
59+ " ",
60+ styles.ApplyBoldForegroundGrad("CRUSH", t.Secondary, t.Primary),
61+ " ",
62+ }
63+
64+ remainingWidth := p.width - lipgloss.Width(strings.Join(parts, "")) - lipgloss.Width(details) - 2
65+ if remainingWidth > 0 {
66+ char := "╱"
67+ lines := strings.Repeat(char, remainingWidth)
68+ parts = append(parts, t.S().Base.Foreground(t.Primary).Render(lines), " ")
69+ }
70+
71+ parts = append(parts, details)
72+
73+ content := t.S().Base.Padding(0, 1).Render(
74+ lipgloss.JoinHorizontal(
75+ lipgloss.Left,
76+ parts...,
77+ ),
78+ )
79+ return tea.NewView(content)
80+}
81+
82+func (h *header) details() string {
83+ t := styles.CurrentTheme()
84+ cwd := fsext.DirTrim(fsext.PrettyPath(config.WorkingDirectory()), 4)
85+ parts := []string{
86+ t.S().Muted.Render(cwd),
87+ }
88+
89+ errorCount := 0
90+ for _, l := range h.lspClients {
91+ for _, diagnostics := range l.GetDiagnostics() {
92+ for _, diagnostic := range diagnostics {
93+ if diagnostic.Severity == protocol.SeverityError {
94+ errorCount++
95+ }
96+ }
97+ }
98+ }
99+
100+ if errorCount > 0 {
101+ parts = append(parts, t.S().Error.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount)))
102+ }
103+
104+ cfg := config.Get()
105+ agentCfg := cfg.Agents[config.AgentCoder]
106+ selectedModelID := agentCfg.Model
107+ model := models.SupportedModels[selectedModelID]
108+
109+ percentage := (float64(h.session.CompletionTokens+h.session.PromptTokens) / float64(model.ContextWindow)) * 100
110+ formattedPercentage := t.S().Muted.Render(fmt.Sprintf("%d%%", int(percentage)))
111+ parts = append(parts, formattedPercentage)
112+
113+ if h.detailsOpen {
114+ parts = append(parts, t.S().Muted.Render("ctrl+d")+t.S().Subtle.Render(" close"))
115+ } else {
116+ parts = append(parts, t.S().Muted.Render("ctrl+d")+t.S().Subtle.Render(" open"))
117+ }
118+ dot := t.S().Subtle.Render(" • ")
119+ return strings.Join(parts, dot)
120+}
121+
122+// SetSession implements Header.
123+func (h *header) SetSession(session session.Session) {
124+ h.session = session
125+}
internal/tui/components/chat/sidebar/sidebar.go+6−6
@@ -223,9 +223,9 @@ func (m *sidebarCmp) loadSessionFiles() tea.Msg {
223}
223}
224
224
225func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
225func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
226− if width < logoBreakpoint && m.width >= logoBreakpoint {
226+ if width < logoBreakpoint && (m.width == 0 || m.width >= logoBreakpoint) {
227 m.logo = m.logoBlock(true)
227 m.logo = m.logoBlock(true)
228− } else if width >= logoBreakpoint && m.width < logoBreakpoint {
228+ } else if width >= logoBreakpoint && (m.width == 0 || m.width < logoBreakpoint) {
229 m.logo = m.logoBlock(false)
229 m.logo = m.logoBlock(false)
230 }
230 }
231
231
@@ -358,16 +358,16 @@ func (m *sidebarCmp) lspBlock() string {
358
358
359 errs := []string{}
359 errs := []string{}
360 if lspErrs[protocol.SeverityError] > 0 {
360 if lspErrs[protocol.SeverityError] > 0 {
361− errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s%d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
361+ errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
362 }
362 }
363 if lspErrs[protocol.SeverityWarning] > 0 {
363 if lspErrs[protocol.SeverityWarning] > 0 {
364− errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s%d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
364+ errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
365 }
365 }
366 if lspErrs[protocol.SeverityHint] > 0 {
366 if lspErrs[protocol.SeverityHint] > 0 {
367− errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s%d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
367+ errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
368 }
368 }
369 if lspErrs[protocol.SeverityInformation] > 0 {
369 if lspErrs[protocol.SeverityInformation] > 0 {
370− errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s%d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
370+ errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
371 }
371 }
372
372
373 lspList = append(lspList,
373 lspList = append(lspList,
internal/tui/components/core/helpers.go+4−4
@@ -72,11 +72,11 @@ func Status(ops StatusOpts, width int) string {
72 }
72 }
73 title = t.S().Base.Foreground(titleColor).Render(title)
73 title = t.S().Base.Foreground(titleColor).Render(title)
74 if description != "" {
74 if description != "" {
75− extraContent := len(ops.ExtraContent)
75+ extraContentWidth := lipgloss.Width(ops.ExtraContent)
76− if extraContent > 0 {
76+ if extraContentWidth > 0 {
77− extraContent += 1
77+ extraContentWidth += 1
78 }
78 }
79− description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContent, "…")
79+ description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContentWidth, "…")
80 }
80 }
81 description = t.S().Base.Foreground(descriptionColor).Render(description)
81 description = t.S().Base.Foreground(descriptionColor).Render(description)
82
82
internal/tui/components/core/layout/container.go+2−2
@@ -12,7 +12,7 @@ type Container interface {
12 util.Model
12 util.Model
13 Sizeable
13 Sizeable
14 Help
14 Help
15− Positionable
15+ Positional
16 Focusable
16 Focusable
17}
17}
18type container struct {
18type container struct {
@@ -151,7 +151,7 @@ func (c *container) GetSize() (int, int) {
151func (c *container) SetPosition(x, y int) tea.Cmd {
151func (c *container) SetPosition(x, y int) tea.Cmd {
152 c.x = x
152 c.x = x
153 c.y = y
153 c.y = y
154− if positionable, ok := c.content.(Positionable); ok {
154+ if positionable, ok := c.content.(Positional); ok {
155 return positionable.SetPosition(x, y)
155 return positionable.SetPosition(x, y)
156 }
156 }
157 return nil
157 return nil
internal/tui/components/core/layout/layout.go+1−1
@@ -20,7 +20,7 @@ type Help interface {
20 Bindings() []key.Binding
20 Bindings() []key.Binding
21}
21}
22
22
23−type Positionable interface {
23+type Positional interface {
24 SetPosition(x, y int) tea.Cmd
24 SetPosition(x, y int) tea.Cmd
25}
25}
26
26
internal/tui/components/core/layout/split.go+19−8
@@ -3,6 +3,7 @@ package layout
3import (
3import (
4 "github.com/charmbracelet/bubbles/v2/key"
4 "github.com/charmbracelet/bubbles/v2/key"
5 tea "github.com/charmbracelet/bubbletea/v2"
5 tea "github.com/charmbracelet/bubbletea/v2"
6+ "github.com/charmbracelet/crush/internal/logging"
6 "github.com/charmbracelet/crush/internal/tui/styles"
7 "github.com/charmbracelet/crush/internal/tui/styles"
7 "github.com/charmbracelet/crush/internal/tui/util"
8 "github.com/charmbracelet/crush/internal/tui/util"
8 "github.com/charmbracelet/lipgloss/v2"
9 "github.com/charmbracelet/lipgloss/v2"
@@ -29,11 +30,14 @@ type SplitPaneLayout interface {
29 ClearBottomPanel() tea.Cmd
30 ClearBottomPanel() tea.Cmd
30
31
31 FocusPanel(panel LayoutPanel) tea.Cmd
32 FocusPanel(panel LayoutPanel) tea.Cmd
33+ SetOffset(x, y int)
32}
34}
33
35
34type splitPaneLayout struct {
36type splitPaneLayout struct {
35− width int
37+ width int
36− height int
38+ height int
39+ xOffset int
40+ yOffset int
37
41
38 ratio float64
42 ratio float64
39 verticalRatio float64
43 verticalRatio float64
@@ -150,6 +154,7 @@ func (s *splitPaneLayout) View() tea.View {
150func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
154func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
151 s.width = width
155 s.width = width
152 s.height = height
156 s.height = height
157+ logging.Info("Setting split pane size", "width", width, "height", height)
153
158
154 var topHeight, bottomHeight int
159 var topHeight, bottomHeight int
155 var cmds []tea.Cmd
160 var cmds []tea.Cmd
@@ -194,24 +199,24 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
194 if s.leftPanel != nil {
199 if s.leftPanel != nil {
195 cmd := s.leftPanel.SetSize(leftWidth, topHeight)
200 cmd := s.leftPanel.SetSize(leftWidth, topHeight)
196 cmds = append(cmds, cmd)
201 cmds = append(cmds, cmd)
197− if positionable, ok := s.leftPanel.(Positionable); ok {
202+ if positional, ok := s.leftPanel.(Positional); ok {
198− cmds = append(cmds, positionable.SetPosition(0, 0))
203+ cmds = append(cmds, positional.SetPosition(s.xOffset, s.yOffset))
199 }
204 }
200 }
205 }
201
206
202 if s.rightPanel != nil {
207 if s.rightPanel != nil {
203 cmd := s.rightPanel.SetSize(rightWidth, topHeight)
208 cmd := s.rightPanel.SetSize(rightWidth, topHeight)
204 cmds = append(cmds, cmd)
209 cmds = append(cmds, cmd)
205− if positionable, ok := s.rightPanel.(Positionable); ok {
210+ if positional, ok := s.rightPanel.(Positional); ok {
206− cmds = append(cmds, positionable.SetPosition(leftWidth, 0))
211+ cmds = append(cmds, positional.SetPosition(s.xOffset+leftWidth, s.yOffset))
207 }
212 }
208 }
213 }
209
214
210 if s.bottomPanel != nil {
215 if s.bottomPanel != nil {
211 cmd := s.bottomPanel.SetSize(width, bottomHeight)
216 cmd := s.bottomPanel.SetSize(width, bottomHeight)
212 cmds = append(cmds, cmd)
217 cmds = append(cmds, cmd)
213− if positionable, ok := s.bottomPanel.(Positionable); ok {
218+ if positional, ok := s.bottomPanel.(Positional); ok {
214− cmds = append(cmds, positionable.SetPosition(0, topHeight))
219+ cmds = append(cmds, positional.SetPosition(s.xOffset, s.yOffset+topHeight))
215 }
220 }
216 }
221 }
217 return tea.Batch(cmds...)
222 return tea.Batch(cmds...)
@@ -308,6 +313,12 @@ func (s *splitPaneLayout) FocusPanel(panel LayoutPanel) tea.Cmd {
308 return tea.Batch(cmds...)
313 return tea.Batch(cmds...)
309}
314}
310
315
316+// SetOffset implements SplitPaneLayout.
317+func (s *splitPaneLayout) SetOffset(x int, y int) {
318+ s.xOffset = x
319+ s.yOffset = y
320+}
321+
311func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
322func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
312 layout := &splitPaneLayout{
323 layout := &splitPaneLayout{
313 ratio: 0.8,
324 ratio: 0.8,
internal/tui/components/dialogs/commands/commands.go+18−5
@@ -54,9 +54,10 @@ type commandDialogCmp struct {
54}
54}
55
55
56type (
56type (
57− SwitchSessionsMsg struct{}
57+ SwitchSessionsMsg struct{}
58− SwitchModelMsg struct{}
58+ SwitchModelMsg struct{}
59+ ToggleCompactModeMsg struct{}
59− CompactMsg struct {
60+ CompactMsg struct {
60 SessionID string
61 SessionID string
61 }
62 }
62)
63)
@@ -111,6 +112,7 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
111 case tea.WindowSizeMsg:
112 case tea.WindowSizeMsg:
112 c.wWidth = msg.Width
113 c.wWidth = msg.Width
113 c.wHeight = msg.Height
114 c.wHeight = msg.Height
115+ c.SetCommandType(c.commandType)
114 return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
116 return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
115 case tea.KeyPressMsg:
117 case tea.KeyPressMsg:
116 switch {
118 switch {
@@ -251,8 +253,8 @@ func (c *commandDialogCmp) defaultCommands() []Command {
251 // Only show compact command if there's an active session
253 // Only show compact command if there's an active session
252 if c.sessionID != "" {
254 if c.sessionID != "" {
253 commands = append(commands, Command{
255 commands = append(commands, Command{
254− ID: "compact",
256+ ID: "Summarize",
255− Title: "Compact Session",
257+ Title: "Summarize Session",
256 Description: "Summarize the current session and create a new one with the summary",
258 Description: "Summarize the current session and create a new one with the summary",
257 Handler: func(cmd Command) tea.Cmd {
259 Handler: func(cmd Command) tea.Cmd {
258 return util.CmdHandler(CompactMsg{
260 return util.CmdHandler(CompactMsg{
@@ -261,6 +263,17 @@ func (c *commandDialogCmp) defaultCommands() []Command {
261 },
263 },
262 })
264 })
263 }
265 }
266+ // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
267+ if c.wWidth > 90 && c.sessionID != "" {
268+ commands = append(commands, Command{
269+ ID: "toggle_sidebar",
270+ Title: "Toggle Sidebar",
271+ Description: "Toggle between compact and normal layout",
272+ Handler: func(cmd Command) tea.Cmd {
273+ return util.CmdHandler(ToggleCompactModeMsg{})
274+ },
275+ })
276+ }
264
277
265 return append(commands, []Command{
278 return append(commands, []Command{
266 {
279 {
internal/tui/page/chat/chat.go+112−7
@@ -2,6 +2,7 @@ package chat
2
2
3import (
3import (
4 "context"
4 "context"
5+ "strings"
5
6
6 "github.com/charmbracelet/bubbles/v2/key"
7 "github.com/charmbracelet/bubbles/v2/key"
7 tea "github.com/charmbracelet/bubbletea/v2"
8 tea "github.com/charmbracelet/bubbletea/v2"
@@ -12,6 +13,7 @@ import (
12 "github.com/charmbracelet/crush/internal/session"
13 "github.com/charmbracelet/crush/internal/session"
13 "github.com/charmbracelet/crush/internal/tui/components/chat"
14 "github.com/charmbracelet/crush/internal/tui/components/chat"
14 "github.com/charmbracelet/crush/internal/tui/components/chat/editor"
15 "github.com/charmbracelet/crush/internal/tui/components/chat/editor"
16+ "github.com/charmbracelet/crush/internal/tui/components/chat/header"
15 "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
17 "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
16 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
18 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
17 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
@@ -21,6 +23,8 @@ import (
21
23
22var ChatPageID page.PageID = "chat"
24var ChatPageID page.PageID = "chat"
23
25
26+const CompactModeBreakpoint = 90 // Width at which the chat page switches to compact mode
27+
24type (
28type (
25 OpenFilePickerMsg struct{}
29 OpenFilePickerMsg struct{}
26 ChatFocusedMsg struct {
30 ChatFocusedMsg struct {
@@ -34,7 +38,8 @@ type ChatPage interface {
34}
38}
35
39
36type chatPage struct {
40type chatPage struct {
41+ wWidth, wHeight int // Window dimensions
37− app *app.App
42+ app *app.App
38
43
39 layout layout.SplitPaneLayout
44 layout layout.SplitPaneLayout
40
45
@@ -43,6 +48,10 @@ type chatPage struct {
43 keyMap KeyMap
48 keyMap KeyMap
44
49
45 chatFocused bool
50 chatFocused bool
51+
52+ compactMode bool
53+ forceCompactMode bool // Force compact mode regardless of window size
54+ header header.Header
46}
55}
47
56
48func (p *chatPage) Init() tea.Cmd {
57func (p *chatPage) Init() tea.Cmd {
@@ -57,13 +66,55 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
57 var cmds []tea.Cmd
66 var cmds []tea.Cmd
58 switch msg := msg.(type) {
67 switch msg := msg.(type) {
59 case tea.WindowSizeMsg:
68 case tea.WindowSizeMsg:
60− cmd := p.layout.SetSize(msg.Width, msg.Height)
69+ h, cmd := p.header.Update(msg)
61 cmds = append(cmds, cmd)
70 cmds = append(cmds, cmd)
71+ p.header = h.(header.Header)
72+ // the mode is only relevant when there is a session
73+ if p.session.ID != "" {
74+ // Only auto-switch to compact mode if not forced
75+ if !p.forceCompactMode {
76+ if msg.Width <= CompactModeBreakpoint && p.wWidth > CompactModeBreakpoint {
77+ p.wWidth = msg.Width
78+ p.wHeight = msg.Height
79+ cmds = append(cmds, p.setCompactMode(true))
80+ return p, tea.Batch(cmds...)
81+ } else if msg.Width > CompactModeBreakpoint && p.wWidth <= CompactModeBreakpoint {
82+ p.wWidth = msg.Width
83+ p.wHeight = msg.Height
84+ return p, p.setCompactMode(false)
85+ }
86+ }
87+ }
88+ p.wWidth = msg.Width
89+ p.wHeight = msg.Height
90+ layoutHeight := msg.Height
91+ if p.compactMode {
92+ // make space for the header
93+ layoutHeight -= 1
94+ }
95+ cmd = p.layout.SetSize(msg.Width, layoutHeight)
96+ cmds = append(cmds, cmd)
97+ return p, tea.Batch(cmds...)
98+
62 case chat.SendMsg:
99 case chat.SendMsg:
63 cmd := p.sendMessage(msg.Text, msg.Attachments)
100 cmd := p.sendMessage(msg.Text, msg.Attachments)
64 if cmd != nil {
101 if cmd != nil {
65 return p, cmd
102 return p, cmd
66 }
103 }
104+ case commands.ToggleCompactModeMsg:
105+ // Only allow toggling if window width is larger than compact breakpoint
106+ if p.wWidth > CompactModeBreakpoint {
107+ p.forceCompactMode = !p.forceCompactMode
108+ // If force compact mode is enabled, switch to compact mode
109+ // If force compact mode is disabled, switch based on window size
110+ if p.forceCompactMode {
111+ return p, p.setCompactMode(true)
112+ } else {
113+ // Return to auto mode based on window size
114+ shouldBeCompact := p.wWidth <= CompactModeBreakpoint
115+ return p, p.setCompactMode(shouldBeCompact)
116+ }
117+ }
67 case commands.CommandRunCustomMsg:
118 case commands.CommandRunCustomMsg:
68 // Check if the agent is busy before executing custom commands
119 // Check if the agent is busy before executing custom commands
69 if p.app.CoderAgent.IsBusy() {
120 if p.app.CoderAgent.IsBusy() {
@@ -82,7 +133,12 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
82 cmds = append(cmds, cmd)
133 cmds = append(cmds, cmd)
83 }
134 }
84 }
135 }
136+ needsModeChange := p.session.ID == ""
85 p.session = msg
137 p.session = msg
138+ p.header.SetSession(msg)
139+ if needsModeChange && (p.wWidth <= CompactModeBreakpoint || p.forceCompactMode) {
140+ cmds = append(cmds, p.setCompactMode(true))
141+ }
86 case tea.KeyPressMsg:
142 case tea.KeyPressMsg:
87 switch {
143 switch {
88 case key.Matches(msg, p.keyMap.NewSession):
144 case key.Matches(msg, p.keyMap.NewSession):
@@ -90,8 +146,8 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
90 return p, tea.Batch(
146 return p, tea.Batch(
91 p.clearMessages(),
147 p.clearMessages(),
92 util.CmdHandler(chat.SessionClearedMsg{}),
148 util.CmdHandler(chat.SessionClearedMsg{}),
149+ p.setCompactMode(false),
93 )
150 )
94−
95 case key.Matches(msg, p.keyMap.AddAttachment):
151 case key.Matches(msg, p.keyMap.AddAttachment):
96 cfg := config.Get()
152 cfg := config.Get()
97 agentCfg := cfg.Agents[config.AgentCoder]
153 agentCfg := cfg.Agents[config.AgentCoder]
@@ -128,6 +184,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
128 cmds = append(cmds, cmd)
184 cmds = append(cmds, cmd)
129 p.layout = u.(layout.SplitPaneLayout)
185 p.layout = u.(layout.SplitPaneLayout)
130
186
187+ h, cmd := p.header.Update(msg)
188+ cmds = append(cmds, cmd)
189+ p.header = h.(header.Header)
131 return p, tea.Batch(cmds...)
190 return p, tea.Batch(cmds...)
132}
191}
133
192
@@ -139,10 +198,38 @@ func (p *chatPage) setMessages() tea.Cmd {
139 return tea.Batch(p.layout.SetLeftPanel(messagesContainer), messagesContainer.Init())
198 return tea.Batch(p.layout.SetLeftPanel(messagesContainer), messagesContainer.Init())
140}
199}
141
200
201+func (p *chatPage) setSidebar() tea.Cmd {
202+ sidebarContainer := sidebarCmp(p.app)
203+ sidebarContainer.Init()
204+ return p.layout.SetRightPanel(sidebarContainer)
205+}
206+
142func (p *chatPage) clearMessages() tea.Cmd {
207func (p *chatPage) clearMessages() tea.Cmd {
143 return p.layout.ClearLeftPanel()
208 return p.layout.ClearLeftPanel()
144}
209}
145
210
211+func (p *chatPage) setCompactMode(compact bool) tea.Cmd {
212+ p.compactMode = compact
213+ var cmds []tea.Cmd
214+ if compact {
215+ // add offset for the header
216+ p.layout.SetOffset(0, 1)
217+ // make space for the header
218+ cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight-1))
219+ // remove the sidebar
220+ cmds = append(cmds, p.layout.ClearRightPanel())
221+ return tea.Batch(cmds...)
222+ } else {
223+ // remove the offset for the header
224+ p.layout.SetOffset(0, 0)
225+ // restore the original size
226+ cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight))
227+ // set the sidebar
228+ cmds = append(cmds, p.setSidebar())
229+ return tea.Batch(cmds...)
230+ }
231+}
232+
146func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
233func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
147 var cmds []tea.Cmd
234 var cmds []tea.Cmd
148 if p.session.ID == "" {
235 if p.session.ID == "" {
@@ -175,7 +262,21 @@ func (p *chatPage) GetSize() (int, int) {
175}
262}
176
263
177func (p *chatPage) View() tea.View {
264func (p *chatPage) View() tea.View {
265+ if !p.compactMode || p.session.ID == "" {
266+ // If not in compact mode or there is no session, we don't show the header
178− return p.layout.View()
267+ return p.layout.View()
268+ }
269+ layoutView := p.layout.View()
270+ chatView := tea.NewView(
271+ strings.Join(
272+ []string{
273+ p.header.View().String(),
274+ layoutView.String(),
275+ }, "\n",
276+ ),
277+ )
278+ chatView.SetCursor(layoutView.Cursor())
279+ return chatView
179}
280}
180
281
181func (p *chatPage) Bindings() []key.Binding {
282func (p *chatPage) Bindings() []key.Binding {
@@ -207,22 +308,26 @@ func (p *chatPage) Bindings() []key.Binding {
207 return bindings
308 return bindings
208}
309}
209
310
210−func NewChatPage(app *app.App) ChatPage {
311+func sidebarCmp(app *app.App) layout.Container {
211− sidebarContainer := layout.NewContainer(
312+ return layout.NewContainer(
212 sidebar.NewSidebarCmp(app.History, app.LSPClients),
313 sidebar.NewSidebarCmp(app.History, app.LSPClients),
213 layout.WithPadding(1, 1, 1, 1),
314 layout.WithPadding(1, 1, 1, 1),
214 )
315 )
316+}
317+
318+func NewChatPage(app *app.App) ChatPage {
215 editorContainer := layout.NewContainer(
319 editorContainer := layout.NewContainer(
216 editor.NewEditorCmp(app),
320 editor.NewEditorCmp(app),
217 )
321 )
218 return &chatPage{
322 return &chatPage{
219 app: app,
323 app: app,
220 layout: layout.NewSplitPane(
324 layout: layout.NewSplitPane(
221− layout.WithRightPanel(sidebarContainer),
325+ layout.WithRightPanel(sidebarCmp(app)),
222 layout.WithBottomPanel(editorContainer),
326 layout.WithBottomPanel(editorContainer),
223 layout.WithFixedBottomHeight(5),
327 layout.WithFixedBottomHeight(5),
224 layout.WithFixedRightWidth(31),
328 layout.WithFixedRightWidth(31),
225 ),
329 ),
226 keyMap: DefaultKeyMap(),
330 keyMap: DefaultKeyMap(),
331+ header: header.New(app.LSPClients),
227 }
332 }
228}
333}
internal/tui/styles/theme.go+27
@@ -582,6 +582,33 @@ func ApplyForegroundGrad(input string, color1, color2 color.Color) string {
582 return o.String()
582 return o.String()
583}
583}
584
584
585+// ApplyBoldForegroundGrad renders a given string with a horizontal gradient
586+// foreground.
587+func ApplyBoldForegroundGrad(input string, color1, color2 color.Color) string {
588+ if input == "" {
589+ return ""
590+ }
591+ t := CurrentTheme()
592+
593+ var o strings.Builder
594+ if len(input) == 1 {
595+ return t.S().Base.Bold(true).Foreground(color1).Render(input)
596+ }
597+
598+ var clusters []string
599+ gr := uniseg.NewGraphemes(input)
600+ for gr.Next() {
601+ clusters = append(clusters, string(gr.Runes()))
602+ }
603+
604+ ramp := blendColors(len(clusters), color1, color2)
605+ for i, c := range ramp {
606+ fmt.Fprint(&o, t.S().Base.Bold(true).Foreground(c).Render(clusters[i]))
607+ }
608+
609+ return o.String()
610+}
611+
585// blendColors returns a slice of colors blended between the given keys.
612// blendColors returns a slice of colors blended between the given keys.
586// Blending is done in Hcl to stay in gamut.
613// Blending is done in Hcl to stay in gamut.
587func blendColors(size int, stops ...color.Color) []color.Color {
614func blendColors(size int, stops ...color.Color) []color.Color {