on main help sign in
feat: responsive layout
commitf19a809ece55925803c9a9dd87cf599ac0f17ea3 authorKujtim Hoxha <kujtimii.h@gmail.com> dateWed 18 Jun 2025, 12:32 (1 year ago) parentb437d0f
9 files changed+314−33
internal/tui/components/chat/header/header.go+125 internal/tui/components/chat/sidebar/sidebar.go+6−6 internal/tui/components/core/helpers.go+4−4 internal/tui/components/core/layout/container.go+2−2 internal/tui/components/core/layout/layout.go+1−1 internal/tui/components/core/layout/split.go+19−8 internal/tui/components/dialogs/commands/commands.go+18−5 internal/tui/page/chat/chat.go+112−7 internal/tui/styles/theme.go+27
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 {
223223}
224224
225225func (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) {
227227 m.logo = m.logoBlock(true)
228− } else if width >= logoBreakpoint && m.width < logoBreakpoint {
228+ } else if width >= logoBreakpoint && (m.width == 0 || m.width < logoBreakpoint) {
229229 m.logo = m.logoBlock(false)
230230 }
231231
@@ -358,16 +358,16 @@ func (m *sidebarCmp) lspBlock() string {
358358
359359 errs := []string{}
360360 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])))
362362 }
363363 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])))
365365 }
366366 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])))
368368 }
369369 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])))
371371 }
372372
373373 lspList = append(lspList,
internal/tui/components/core/helpers.go+4−4
@@ -72,11 +72,11 @@ func Status(ops StatusOpts, width int) string {
7272 }
7373 title = t.S().Base.Foreground(titleColor).Render(title)
7474 if description != "" {
75− extraContent := len(ops.ExtraContent)
76− if extraContent > 0 {
77− extraContent += 1
75+ extraContentWidth := lipgloss.Width(ops.ExtraContent)
76+ if extraContentWidth > 0 {
77+ extraContentWidth += 1
7878 }
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, "…")
8080 }
8181 description = t.S().Base.Foreground(descriptionColor).Render(description)
8282
internal/tui/components/core/layout/container.go+2−2
@@ -12,7 +12,7 @@ type Container interface {
1212 util.Model
1313 Sizeable
1414 Help
15− Positionable
15+ Positional
1616 Focusable
1717}
1818type container struct {
@@ -151,7 +151,7 @@ func (c *container) GetSize() (int, int) {
151151func (c *container) SetPosition(x, y int) tea.Cmd {
152152 c.x = x
153153 c.y = y
154− if positionable, ok := c.content.(Positionable); ok {
154+ if positionable, ok := c.content.(Positional); ok {
155155 return positionable.SetPosition(x, y)
156156 }
157157 return nil
internal/tui/components/core/layout/layout.go+1−1
@@ -20,7 +20,7 @@ type Help interface {
2020 Bindings() []key.Binding
2121}
2222
23−type Positionable interface {
23+type Positional interface {
2424 SetPosition(x, y int) tea.Cmd
2525}
2626
internal/tui/components/core/layout/split.go+19−8
@@ -3,6 +3,7 @@ package layout
33import (
44 "github.com/charmbracelet/bubbles/v2/key"
55 tea "github.com/charmbracelet/bubbletea/v2"
6+ "github.com/charmbracelet/crush/internal/logging"
67 "github.com/charmbracelet/crush/internal/tui/styles"
78 "github.com/charmbracelet/crush/internal/tui/util"
89 "github.com/charmbracelet/lipgloss/v2"
@@ -29,11 +30,14 @@ type SplitPaneLayout interface {
2930 ClearBottomPanel() tea.Cmd
3031
3132 FocusPanel(panel LayoutPanel) tea.Cmd
33+ SetOffset(x, y int)
3234}
3335
3436type splitPaneLayout struct {
35− width int
36− height int
37+ width int
38+ height int
39+ xOffset int
40+ yOffset int
3741
3842 ratio float64
3943 verticalRatio float64
@@ -150,6 +154,7 @@ func (s *splitPaneLayout) View() tea.View {
150154func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
151155 s.width = width
152156 s.height = height
157+ logging.Info("Setting split pane size", "width", width, "height", height)
153158
154159 var topHeight, bottomHeight int
155160 var cmds []tea.Cmd
@@ -194,24 +199,24 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
194199 if s.leftPanel != nil {
195200 cmd := s.leftPanel.SetSize(leftWidth, topHeight)
196201 cmds = append(cmds, cmd)
197− if positionable, ok := s.leftPanel.(Positionable); ok {
198− cmds = append(cmds, positionable.SetPosition(0, 0))
202+ if positional, ok := s.leftPanel.(Positional); ok {
203+ cmds = append(cmds, positional.SetPosition(s.xOffset, s.yOffset))
199204 }
200205 }
201206
202207 if s.rightPanel != nil {
203208 cmd := s.rightPanel.SetSize(rightWidth, topHeight)
204209 cmds = append(cmds, cmd)
205− if positionable, ok := s.rightPanel.(Positionable); ok {
206− cmds = append(cmds, positionable.SetPosition(leftWidth, 0))
210+ if positional, ok := s.rightPanel.(Positional); ok {
211+ cmds = append(cmds, positional.SetPosition(s.xOffset+leftWidth, s.yOffset))
207212 }
208213 }
209214
210215 if s.bottomPanel != nil {
211216 cmd := s.bottomPanel.SetSize(width, bottomHeight)
212217 cmds = append(cmds, cmd)
213− if positionable, ok := s.bottomPanel.(Positionable); ok {
214− cmds = append(cmds, positionable.SetPosition(0, topHeight))
218+ if positional, ok := s.bottomPanel.(Positional); ok {
219+ cmds = append(cmds, positional.SetPosition(s.xOffset, s.yOffset+topHeight))
215220 }
216221 }
217222 return tea.Batch(cmds...)
@@ -308,6 +313,12 @@ func (s *splitPaneLayout) FocusPanel(panel LayoutPanel) tea.Cmd {
308313 return tea.Batch(cmds...)
309314}
310315
316+// SetOffset implements SplitPaneLayout.
317+func (s *splitPaneLayout) SetOffset(x int, y int) {
318+ s.xOffset = x
319+ s.yOffset = y
320+}
321+
311322func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
312323 layout := &splitPaneLayout{
313324 ratio: 0.8,
internal/tui/components/dialogs/commands/commands.go+18−5
@@ -54,9 +54,10 @@ type commandDialogCmp struct {
5454}
5555
5656type (
57− SwitchSessionsMsg struct{}
58− SwitchModelMsg struct{}
59− CompactMsg struct {
57+ SwitchSessionsMsg struct{}
58+ SwitchModelMsg struct{}
59+ ToggleCompactModeMsg struct{}
60+ CompactMsg struct {
6061 SessionID string
6162 }
6263)
@@ -111,6 +112,7 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
111112 case tea.WindowSizeMsg:
112113 c.wWidth = msg.Width
113114 c.wHeight = msg.Height
115+ c.SetCommandType(c.commandType)
114116 return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
115117 case tea.KeyPressMsg:
116118 switch {
@@ -251,8 +253,8 @@ func (c *commandDialogCmp) defaultCommands() []Command {
251253 // Only show compact command if there's an active session
252254 if c.sessionID != "" {
253255 commands = append(commands, Command{
254− ID: "compact",
255− Title: "Compact Session",
256+ ID: "Summarize",
257+ Title: "Summarize Session",
256258 Description: "Summarize the current session and create a new one with the summary",
257259 Handler: func(cmd Command) tea.Cmd {
258260 return util.CmdHandler(CompactMsg{
@@ -261,6 +263,17 @@ func (c *commandDialogCmp) defaultCommands() []Command {
261263 },
262264 })
263265 }
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+ }
264277
265278 return append(commands, []Command{
266279 {
internal/tui/page/chat/chat.go+112−7
@@ -2,6 +2,7 @@ package chat
22
33import (
44 "context"
5+ "strings"
56
67 "github.com/charmbracelet/bubbles/v2/key"
78 tea "github.com/charmbracelet/bubbletea/v2"
@@ -12,6 +13,7 @@ import (
1213 "github.com/charmbracelet/crush/internal/session"
1314 "github.com/charmbracelet/crush/internal/tui/components/chat"
1415 "github.com/charmbracelet/crush/internal/tui/components/chat/editor"
16+ "github.com/charmbracelet/crush/internal/tui/components/chat/header"
1517 "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
1618 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
1719 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
@@ -21,6 +23,8 @@ import (
2123
2224var ChatPageID page.PageID = "chat"
2325
26+const CompactModeBreakpoint = 90 // Width at which the chat page switches to compact mode
27+
2428type (
2529 OpenFilePickerMsg struct{}
2630 ChatFocusedMsg struct {
@@ -34,7 +38,8 @@ type ChatPage interface {
3438}
3539
3640type chatPage struct {
37− app *app.App
41+ wWidth, wHeight int // Window dimensions
42+ app *app.App
3843
3944 layout layout.SplitPaneLayout
4045
@@ -43,6 +48,10 @@ type chatPage struct {
4348 keyMap KeyMap
4449
4550 chatFocused bool
51+
52+ compactMode bool
53+ forceCompactMode bool // Force compact mode regardless of window size
54+ header header.Header
4655}
4756
4857func (p *chatPage) Init() tea.Cmd {
@@ -57,13 +66,55 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
5766 var cmds []tea.Cmd
5867 switch msg := msg.(type) {
5968 case tea.WindowSizeMsg:
60− cmd := p.layout.SetSize(msg.Width, msg.Height)
69+ h, cmd := p.header.Update(msg)
6170 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+
6299 case chat.SendMsg:
63100 cmd := p.sendMessage(msg.Text, msg.Attachments)
64101 if cmd != nil {
65102 return p, cmd
66103 }
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+ }
67118 case commands.CommandRunCustomMsg:
68119 // Check if the agent is busy before executing custom commands
69120 if p.app.CoderAgent.IsBusy() {
@@ -82,7 +133,12 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
82133 cmds = append(cmds, cmd)
83134 }
84135 }
136+ needsModeChange := p.session.ID == ""
85137 p.session = msg
138+ p.header.SetSession(msg)
139+ if needsModeChange && (p.wWidth <= CompactModeBreakpoint || p.forceCompactMode) {
140+ cmds = append(cmds, p.setCompactMode(true))
141+ }
86142 case tea.KeyPressMsg:
87143 switch {
88144 case key.Matches(msg, p.keyMap.NewSession):
@@ -90,8 +146,8 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
90146 return p, tea.Batch(
91147 p.clearMessages(),
92148 util.CmdHandler(chat.SessionClearedMsg{}),
149+ p.setCompactMode(false),
93150 )
94−
95151 case key.Matches(msg, p.keyMap.AddAttachment):
96152 cfg := config.Get()
97153 agentCfg := cfg.Agents[config.AgentCoder]
@@ -128,6 +184,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
128184 cmds = append(cmds, cmd)
129185 p.layout = u.(layout.SplitPaneLayout)
130186
187+ h, cmd := p.header.Update(msg)
188+ cmds = append(cmds, cmd)
189+ p.header = h.(header.Header)
131190 return p, tea.Batch(cmds...)
132191}
133192
@@ -139,10 +198,38 @@ func (p *chatPage) setMessages() tea.Cmd {
139198 return tea.Batch(p.layout.SetLeftPanel(messagesContainer), messagesContainer.Init())
140199}
141200
201+func (p *chatPage) setSidebar() tea.Cmd {
202+ sidebarContainer := sidebarCmp(p.app)
203+ sidebarContainer.Init()
204+ return p.layout.SetRightPanel(sidebarContainer)
205+}
206+
142207func (p *chatPage) clearMessages() tea.Cmd {
143208 return p.layout.ClearLeftPanel()
144209}
145210
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+
146233func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
147234 var cmds []tea.Cmd
148235 if p.session.ID == "" {
@@ -175,7 +262,21 @@ func (p *chatPage) GetSize() (int, int) {
175262}
176263
177264func (p *chatPage) View() tea.View {
178− return p.layout.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
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
179280}
180281
181282func (p *chatPage) Bindings() []key.Binding {
@@ -207,22 +308,26 @@ func (p *chatPage) Bindings() []key.Binding {
207308 return bindings
208309}
209310
210−func NewChatPage(app *app.App) ChatPage {
211− sidebarContainer := layout.NewContainer(
311+func sidebarCmp(app *app.App) layout.Container {
312+ return layout.NewContainer(
212313 sidebar.NewSidebarCmp(app.History, app.LSPClients),
213314 layout.WithPadding(1, 1, 1, 1),
214315 )
316+}
317+
318+func NewChatPage(app *app.App) ChatPage {
215319 editorContainer := layout.NewContainer(
216320 editor.NewEditorCmp(app),
217321 )
218322 return &chatPage{
219323 app: app,
220324 layout: layout.NewSplitPane(
221− layout.WithRightPanel(sidebarContainer),
325+ layout.WithRightPanel(sidebarCmp(app)),
222326 layout.WithBottomPanel(editorContainer),
223327 layout.WithFixedBottomHeight(5),
224328 layout.WithFixedRightWidth(31),
225329 ),
226330 keyMap: DefaultKeyMap(),
331+ header: header.New(app.LSPClients),
227332 }
228333}
internal/tui/styles/theme.go+27
@@ -582,6 +582,33 @@ func ApplyForegroundGrad(input string, color1, color2 color.Color) string {
582582 return o.String()
583583}
584584
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+
585612// blendColors returns a slice of colors blended between the given keys.
586613// Blending is done in Hcl to stay in gamut.
587614func blendColors(size int, stops ...color.Color) []color.Color {