Detailed changes
@@ -31,6 +31,7 @@ require (
github.com/charmbracelet/x/term v0.2.2
github.com/denisbrodbeck/machineid v1.0.1
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
+ github.com/dustin/go-humanize v1.0.1
github.com/google/uuid v1.6.0
github.com/invopop/jsonschema v0.13.0
github.com/joho/godotenv v1.5.1
@@ -101,7 +102,6 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/disintegration/gift v1.1.2 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
- github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
@@ -144,3 +144,17 @@ func Section(t *styles.Styles, text string, width int) string {
}
return text
}
+
+// DialogTitle renders a dialog title with a decorative line filling the
+// remaining width.
+func DialogTitle(t *styles.Styles, title string, width int) string {
+ char := "╱"
+ length := lipgloss.Width(title) + 1
+ remainingWidth := width - length
+ if remainingWidth > 0 {
+ lines := strings.Repeat(char, remainingWidth)
+ lines = styles.ApplyForegroundGrad(t, lines, t.Primary, t.Secondary)
+ title = title + " " + lines
+ }
+ return title
+}
@@ -25,6 +25,8 @@ const (
ActionNone ActionType = iota
// ActionClose indicates that the dialog should be closed.
ActionClose
+ // ActionSelect indicates that an item has been selected.
+ ActionSelect
)
// Action represents an action taken by a dialog.
@@ -81,6 +83,16 @@ func (d *Overlay) AddDialog(dialog Dialog) {
d.dialogs = append(d.dialogs, dialog)
}
+// RemoveDialog removes the dialog with the specified ID from the stack.
+func (d *Overlay) RemoveDialog(dialogID string) {
+ for i, dialog := range d.dialogs {
+ if dialog.ID() == dialogID {
+ d.removeDialog(i)
+ return
+ }
+ }
+}
+
// BringToFront brings the dialog with the specified ID to the front.
func (d *Overlay) BringToFront(dialogID string) {
for i, dialog := range d.dialogs {
@@ -116,6 +128,9 @@ func (d *Overlay) Update(msg tea.Msg) (*Overlay, tea.Cmd) {
// Close the current dialog
d.removeDialog(idx)
return d, cmd
+ case ActionSelect:
+ // Pass the action up (without modifying the dialog stack)
+ return d, cmd
}
return d, cmd
@@ -0,0 +1,156 @@
+package dialog
+
+import (
+ "strings"
+ "time"
+
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/crush/internal/session"
+ "github.com/charmbracelet/crush/internal/ui/list"
+ "github.com/charmbracelet/crush/internal/ui/styles"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/dustin/go-humanize"
+ "github.com/rivo/uniseg"
+ "github.com/sahilm/fuzzy"
+)
+
+// ListItem represents a selectable and searchable item in a dialog list.
+type ListItem interface {
+ list.FilterableItem
+ list.FocusStylable
+ list.MatchSettable
+
+ // ID returns the unique identifier of the item.
+ ID() string
+}
+
+// SessionItem wraps a [session.Session] to implement the [ListItem] interface.
+type SessionItem struct {
+ session.Session
+ t *styles.Styles
+ m fuzzy.Match
+}
+
+var _ ListItem = &SessionItem{}
+
+// Filter returns the filterable value of the session.
+func (s *SessionItem) Filter() string {
+ return s.Session.Title
+}
+
+// ID returns the unique identifier of the session.
+func (s *SessionItem) ID() string {
+ return s.Session.ID
+}
+
+// SetMatch sets the fuzzy match for the session item.
+func (s *SessionItem) SetMatch(m fuzzy.Match) {
+ s.m = m
+}
+
+// Render returns the string representation of the session item.
+func (s *SessionItem) Render(width int) string {
+ age := humanize.Time(time.Unix(s.Session.UpdatedAt, 0))
+ age = s.t.Subtle.Render(age)
+ age = " " + age
+ ageLen := lipgloss.Width(age)
+ title := s.Session.Title
+ titleLen := lipgloss.Width(title)
+ title = ansi.Truncate(title, max(0, width-ageLen), "…")
+ right := lipgloss.NewStyle().AlignHorizontal(lipgloss.Right).Width(width - titleLen).Render(age)
+
+ if matches := len(s.m.MatchedIndexes); matches > 0 {
+ var lastPos int
+ parts := make([]string, 0)
+ // TODO: Use [ansi.Style].Underline true/false to underline only the
+ // matched parts instead of using [lipgloss.StyleRanges] since it can
+ // be cheaper with less allocations.
+ ranges := matchedRanges(s.m.MatchedIndexes)
+ for _, rng := range ranges {
+ start, stop := bytePosToVisibleCharPos(title, rng)
+ if start > lastPos {
+ parts = append(parts, title[lastPos:start])
+ }
+ // NOTE: We're using [ansi.Style] here instead of [lipgloss.Style]
+ // because we can control the underline start and stop more
+ // precisely via [ansi.AttrUnderline] and [ansi.AttrNoUnderline]
+ // which only affect the underline attribute without interfering
+ // with other styles.
+ parts = append(parts,
+ ansi.NewStyle().Underline(true).String(),
+ title[start:stop+1],
+ ansi.NewStyle().Underline(false).String(),
+ )
+ lastPos = stop + 1
+ }
+ if lastPos < len(title) {
+ parts = append(parts, title[lastPos:])
+ }
+ return strings.Join(parts, "") + right
+ }
+ return title + right
+}
+
+// FocusStyle returns the style to be applied when the item is focused.
+func (s *SessionItem) FocusStyle() lipgloss.Style {
+ return s.t.Dialog.SelectedItem
+}
+
+// BlurStyle returns the style to be applied when the item is blurred.
+func (s *SessionItem) BlurStyle() lipgloss.Style {
+ return s.t.Dialog.NormalItem
+}
+
+// sessionItems takes a slice of [session.Session]s and convert them to a slice
+// of [ListItem]s.
+func sessionItems(t *styles.Styles, sessions ...session.Session) []list.FilterableItem {
+ items := make([]list.FilterableItem, len(sessions))
+ for i, s := range sessions {
+ items[i] = &SessionItem{Session: s, t: t}
+ }
+ return items
+}
+
+func matchedRanges(in []int) [][2]int {
+ if len(in) == 0 {
+ return [][2]int{}
+ }
+ current := [2]int{in[0], in[0]}
+ if len(in) == 1 {
+ return [][2]int{current}
+ }
+ var out [][2]int
+ for i := 1; i < len(in); i++ {
+ if in[i] == current[1]+1 {
+ current[1] = in[i]
+ } else {
+ out = append(out, current)
+ current = [2]int{in[i], in[i]}
+ }
+ }
+ out = append(out, current)
+ return out
+}
+
+func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
+ bytePos, byteStart, byteStop := 0, rng[0], rng[1]
+ pos, start, stop := 0, 0, 0
+ gr := uniseg.NewGraphemes(str)
+ for byteStart > bytePos {
+ if !gr.Next() {
+ break
+ }
+ bytePos += len(gr.Str())
+ pos += max(1, gr.Width())
+ }
+ start = pos
+ for byteStop > bytePos {
+ if !gr.Next() {
+ break
+ }
+ bytePos += len(gr.Str())
+ pos += max(1, gr.Width())
+ }
+ stop = pos
+ return start, stop
+}
@@ -0,0 +1,196 @@
+package dialog
+
+import (
+ "strings"
+
+ "charm.land/bubbles/v2/help"
+ "charm.land/bubbles/v2/key"
+ "charm.land/bubbles/v2/textinput"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/crush/internal/session"
+ "github.com/charmbracelet/crush/internal/ui/common"
+ "github.com/charmbracelet/crush/internal/ui/list"
+)
+
+// SessionDialogID is the identifier for the session selector dialog.
+const SessionDialogID = "session"
+
+// Session is a session selector dialog.
+type Session struct {
+ width, height int
+ com *common.Common
+ help help.Model
+ list *list.FilterableList
+ input textinput.Model
+
+ keyMap struct {
+ Select key.Binding
+ Next key.Binding
+ Previous key.Binding
+ Close key.Binding
+ }
+}
+
+var _ Dialog = (*Session)(nil)
+
+// SessionSelectedMsg is a message sent when a session is selected.
+type SessionSelectedMsg struct {
+ Session session.Session
+}
+
+// NewSessions creates a new Session dialog.
+func NewSessions(com *common.Common, sessions ...session.Session) *Session {
+ s := new(Session)
+ s.com = com
+ help := help.New()
+ help.Styles = com.Styles.DialogHelpStyles()
+
+ s.help = help
+ s.list = list.NewFilterableList(sessionItems(com.Styles, sessions...)...)
+ s.list.Focus()
+ s.list.SetSelected(0)
+
+ s.input = textinput.New()
+ s.input.SetVirtualCursor(false)
+ s.input.Placeholder = "Enter session name"
+ s.input.SetStyles(com.Styles.TextInput)
+ s.input.Focus()
+
+ s.keyMap.Select = key.NewBinding(
+ key.WithKeys("enter", "tab", "ctrl+y"),
+ key.WithHelp("enter", "choose"),
+ )
+ s.keyMap.Next = key.NewBinding(
+ key.WithKeys("down", "ctrl+n"),
+ key.WithHelp("↓", "next item"),
+ )
+ s.keyMap.Previous = key.NewBinding(
+ key.WithKeys("up", "ctrl+p"),
+ key.WithHelp("↑", "previous item"),
+ )
+ s.keyMap.Close = CloseKey
+ return s
+}
+
+// Cursor returns the cursor position relative to the dialog.
+func (s *Session) Cursor() *tea.Cursor {
+ return s.input.Cursor()
+}
+
+// SetSize sets the size of the dialog.
+func (s *Session) SetSize(width, height int) {
+ s.width = width
+ s.height = height
+ innerWidth := width - s.com.Styles.Dialog.View.GetHorizontalFrameSize()
+ s.input.SetWidth(innerWidth - s.com.Styles.Dialog.InputPrompt.GetHorizontalFrameSize() - 1)
+ s.list.SetSize(innerWidth, height-6) // (1) title + (3) input + (1) padding + (1) help
+ s.help.SetWidth(width)
+}
+
+// SelectedItem returns the currently selected item. It may be nil if no item
+// is selected.
+func (s *Session) SelectedItem() list.Item {
+ return s.list.SelectedItem()
+}
+
+// ID implements Dialog.
+func (s *Session) ID() string {
+ return SessionDialogID
+}
+
+// Update implements Dialog.
+func (s *Session) Update(msg tea.Msg) (Action, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyPressMsg:
+ switch {
+ case key.Matches(msg, s.keyMap.Previous):
+ s.list.Focus()
+ s.list.SelectPrev()
+ s.list.ScrollToSelected()
+ case key.Matches(msg, s.keyMap.Next):
+ s.list.Focus()
+ s.list.SelectNext()
+ s.list.ScrollToSelected()
+ case key.Matches(msg, s.keyMap.Select):
+ if item := s.list.SelectedItem(); item != nil {
+ sessionItem := item.(*SessionItem)
+ return Action{Type: ActionSelect, Payload: sessionItem.Session}, SessionSelectCmd(sessionItem.Session)
+ }
+ default:
+ var cmd tea.Cmd
+ s.input, cmd = s.input.Update(msg)
+ s.list.SetFilter(s.input.Value())
+ return Action{}, cmd
+ }
+ }
+ return Action{}, nil
+}
+
+// Layer implements Dialog.
+func (s *Session) Layer() *lipgloss.Layer {
+ titleStyle := s.com.Styles.Dialog.Title
+ helpStyle := s.com.Styles.Dialog.HelpView
+ dialogStyle := s.com.Styles.Dialog.View.Width(s.width)
+ inputStyle := s.com.Styles.Dialog.InputPrompt
+ helpStyle = helpStyle.Width(s.width - dialogStyle.GetHorizontalFrameSize())
+ listContent := s.list.Render()
+ if nlines := lipgloss.Height(listContent); nlines < s.list.Height() {
+ // pad the list content to avoid jumping when navigating
+ listContent += strings.Repeat("\n", max(0, s.list.Height()-nlines))
+ }
+
+ content := strings.Join([]string{
+ titleStyle.Render(
+ common.DialogTitle(
+ s.com.Styles,
+ "Switch Session",
+ max(0, s.width-
+ dialogStyle.GetHorizontalFrameSize()-
+ titleStyle.GetHorizontalFrameSize()))),
+ "",
+ inputStyle.Render(s.input.View()),
+ "",
+ listContent,
+ "",
+ helpStyle.Render(s.help.View(s)),
+ }, "\n")
+
+ return lipgloss.NewLayer(dialogStyle.Render(content))
+}
+
+// ShortHelp implements [help.KeyMap].
+func (s *Session) ShortHelp() []key.Binding {
+ updown := key.NewBinding(
+ key.WithKeys("down", "up"),
+ key.WithHelp("↑↓", "choose"),
+ )
+ return []key.Binding{
+ updown,
+ s.keyMap.Select,
+ s.keyMap.Close,
+ }
+}
+
+// FullHelp implements [help.KeyMap].
+func (s *Session) FullHelp() [][]key.Binding {
+ m := [][]key.Binding{}
+ slice := []key.Binding{
+ s.keyMap.Select,
+ s.keyMap.Next,
+ s.keyMap.Previous,
+ s.keyMap.Close,
+ }
+ for i := 0; i < len(slice); i += 4 {
+ end := min(i+4, len(slice))
+ m = append(m, slice[i:end])
+ }
+ return m
+}
+
+// SessionSelectCmd creates a command that sends a SessionSelectMsg.
+func SessionSelectCmd(s session.Session) tea.Cmd {
+ return func() tea.Msg {
+ return SessionSelectedMsg{Session: s}
+ }
+}
@@ -0,0 +1,118 @@
+package list
+
+import (
+ "github.com/sahilm/fuzzy"
+)
+
+// FilterableItem is an item that can be filtered via a query.
+type FilterableItem interface {
+ Item
+ // Filter returns the value to be used for filtering.
+ Filter() string
+}
+
+// MatchSettable is an interface for items that can have their match indexes
+// and match score set.
+type MatchSettable interface {
+ SetMatch(fuzzy.Match)
+}
+
+// FilterableList is a list that takes filterable items that can be filtered
+// via a settable query.
+type FilterableList struct {
+ *List
+ items []FilterableItem
+ query string
+}
+
+// NewFilterableList creates a new filterable list.
+func NewFilterableList(items ...FilterableItem) *FilterableList {
+ f := &FilterableList{
+ List: NewList(),
+ items: items,
+ }
+ f.SetItems(items...)
+ return f
+}
+
+// SetItems sets the list items and updates the filtered items.
+func (f *FilterableList) SetItems(items ...FilterableItem) {
+ f.items = items
+ fitems := make([]Item, len(items))
+ for i, item := range items {
+ fitems[i] = item
+ }
+ f.List.SetItems(fitems...)
+}
+
+// AppendItems appends items to the list and updates the filtered items.
+func (f *FilterableList) AppendItems(items ...FilterableItem) {
+ f.items = append(f.items, items...)
+ itms := make([]Item, len(f.items))
+ for i, item := range f.items {
+ itms[i] = item
+ }
+ f.List.SetItems(itms...)
+}
+
+// PrependItems prepends items to the list and updates the filtered items.
+func (f *FilterableList) PrependItems(items ...FilterableItem) {
+ f.items = append(items, f.items...)
+ itms := make([]Item, len(f.items))
+ for i, item := range f.items {
+ itms[i] = item
+ }
+ f.List.SetItems(itms...)
+}
+
+// SetFilter sets the filter query and updates the list items.
+func (f *FilterableList) SetFilter(q string) {
+ f.query = q
+}
+
+type filterableItems []FilterableItem
+
+func (f filterableItems) Len() int {
+ return len(f)
+}
+
+func (f filterableItems) String(i int) string {
+ return f[i].Filter()
+}
+
+// VisibleItems returns the visible items after filtering.
+func (f *FilterableList) VisibleItems() []Item {
+ if f.query == "" {
+ items := make([]Item, len(f.items))
+ for i, item := range f.items {
+ if ms, ok := item.(MatchSettable); ok {
+ ms.SetMatch(fuzzy.Match{})
+ item = ms.(FilterableItem)
+ }
+ items[i] = item
+ }
+ return items
+ }
+
+ items := filterableItems(f.items)
+ matches := fuzzy.FindFrom(f.query, items)
+ matchedItems := []Item{}
+ resultSize := len(matches)
+ for i := range resultSize {
+ match := matches[i]
+ item := items[match.Index]
+ if ms, ok := item.(MatchSettable); ok {
+ ms.SetMatch(match)
+ item = ms.(FilterableItem)
+ }
+ matchedItems = append(matchedItems, item)
+ }
+
+ return matchedItems
+}
+
+// Render renders the filterable list.
+func (f *FilterableList) Render() string {
+ f.List.SetItems(f.VisibleItems()...)
+ return f.List.Render()
+}
@@ -162,6 +162,7 @@ func (l *List) renderItem(idx int, process bool) renderedItem {
if !ok {
item := l.items[idx]
rendered := item.Render(l.width - style.GetHorizontalFrameSize())
+ rendered = strings.TrimRight(rendered, "\n")
height := countLines(rendered)
ri = renderedItem{
@@ -387,6 +388,23 @@ func (l *List) PrependItems(items ...Item) {
}
}
+// SetItems sets the items in the list.
+func (l *List) SetItems(items ...Item) {
+ l.setItems(true, items...)
+}
+
+// setItems sets the items in the list. If evict is true, it clears the
+// rendered item cache.
+func (l *List) setItems(evict bool, items ...Item) {
+ l.items = items
+ if evict {
+ l.renderedItems = make(map[int]renderedItem)
+ }
+ l.selectedIdx = min(l.selectedIdx, len(l.items)-1)
+ l.offsetIdx = min(l.offsetIdx, len(l.items)-1)
+ l.offsetLine = 0
+}
+
// AppendItems appends items to the list.
func (l *List) AppendItems(items ...Item) {
l.items = append(l.items, items...)
@@ -514,6 +532,15 @@ func (l *List) SelectLast() {
}
}
+// SelectedItem returns the currently selected item. It may be nil if no item
+// is selected.
+func (l *List) SelectedItem() Item {
+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
+ return nil
+ }
+ return l.items[l.selectedIdx]
+}
+
// SelectFirstInView selects the first item currently in view.
func (l *List) SelectFirstInView() {
startIdx, _ := l.findVisibleItems()
@@ -50,6 +50,16 @@ func (m *Chat) PrependItems(items ...list.Item) {
m.list.ScrollToIndex(0)
}
+// SetMessages sets the chat messages to the provided list of message items.
+func (m *Chat) SetMessages(msgs ...MessageItem) {
+ items := make([]list.Item, len(msgs))
+ for i, msg := range msgs {
+ items[i] = msg
+ }
+ m.list.SetItems(items...)
+ m.list.ScrollToBottom()
+}
+
// AppendMessages appends a new message item to the chat list.
func (m *Chat) AppendMessages(msgs ...MessageItem) {
items := make([]list.Item, len(msgs))
@@ -50,6 +50,11 @@ const (
uiChatCompact
)
+// sessionsLoadedMsg is a message indicating that sessions have been loaded.
+type sessionsLoadedMsg struct {
+ sessions []session.Session
+}
+
type sessionLoadedMsg struct {
sess session.Session
}
@@ -167,13 +172,6 @@ func (m *UI) Init() tea.Cmd {
if m.QueryVersion {
cmds = append(cmds, tea.RequestTerminalVersion)
}
- allSessions, _ := m.com.App.Sessions.List(context.Background())
- if len(allSessions) > 0 {
- cmds = append(cmds, func() tea.Msg {
- // time.Sleep(2 * time.Second)
- return m.loadSession(allSessions[0].ID)()
- })
- }
return tea.Batch(cmds...)
}
@@ -190,6 +188,16 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if !m.sendProgressBar {
m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
}
+ case sessionsLoadedMsg:
+ sessions := dialog.NewSessions(m.com, msg.sessions...)
+ sessions.SetSize(min(120, m.width-8), 30)
+ m.dialog.AddDialog(sessions)
+ case dialog.SessionSelectedMsg:
+ m.dialog.RemoveDialog(dialog.SessionDialogID)
+ cmds = append(cmds,
+ m.loadSession(msg.Session.ID),
+ m.loadSessionFiles(msg.Session.ID),
+ )
case sessionLoadedMsg:
m.state = uiChat
m.session = &msg.sess
@@ -208,7 +216,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
for _, msg := range msgPtrs {
items = append(items, GetMessageItems(m.com.Styles, msg, toolResultMap)...)
}
- m.chat.AppendMessages(items...)
+
+ m.chat.SetMessages(items...)
// Notify that session loading is done to scroll to bottom. This is
// needed because we need to draw the chat list first before we can
@@ -361,7 +370,12 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
case key.Matches(msg, m.keyMap.Models):
// TODO: Implement me
case key.Matches(msg, m.keyMap.Sessions):
- // TODO: Implement me
+ if m.dialog.ContainsDialog(dialog.SessionDialogID) {
+ // Bring to front
+ m.dialog.BringToFront(dialog.SessionDialogID)
+ } else {
+ cmds = append(cmds, m.loadSessionsCmd)
+ }
return true
}
return false
@@ -955,6 +969,13 @@ func (m *UI) renderSidebarLogo(width int) {
m.sidebarLogo = renderLogo(m.com.Styles, true, width)
}
+// loadSessionsCmd loads the list of sessions and returns a command that sends
+// a sessionFilesLoadedMsg when done.
+func (m *UI) loadSessionsCmd() tea.Msg {
+ allSessions, _ := m.com.App.Sessions.List(context.TODO())
+ return sessionsLoadedMsg{sessions: allSessions}
+}
+
// renderLogo renders the Crush logo with the given styles and dimensions.
func renderLogo(t *styles.Styles, compact bool, width int) string {
return logo.Render(version.Version, compact, logo.Opts{
@@ -0,0 +1,117 @@
+package styles
+
+import (
+ "fmt"
+ "image/color"
+ "strings"
+
+ "github.com/lucasb-eyer/go-colorful"
+ "github.com/rivo/uniseg"
+)
+
+// ForegroundGrad returns a slice of strings representing the input string
+// rendered with a horizontal gradient foreground from color1 to color2. Each
+// string in the returned slice corresponds to a grapheme cluster in the input
+// string. If bold is true, the rendered strings will be bolded.
+func ForegroundGrad(t *Styles, input string, bold bool, color1, color2 color.Color) []string {
+ if input == "" {
+ return []string{""}
+ }
+ if len(input) == 1 {
+ style := t.Base.Foreground(color1)
+ if bold {
+ style.Bold(true)
+ }
+ return []string{style.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 {
+ style := t.Base.Foreground(c)
+ if bold {
+ style.Bold(true)
+ }
+ clusters[i] = style.Render(clusters[i])
+ }
+ return clusters
+}
+
+// ApplyForegroundGrad renders a given string with a horizontal gradient
+// foreground.
+func ApplyForegroundGrad(t *Styles, input string, color1, color2 color.Color) string {
+ if input == "" {
+ return ""
+ }
+ var o strings.Builder
+ clusters := ForegroundGrad(t, input, false, color1, color2)
+ for _, c := range clusters {
+ fmt.Fprint(&o, c)
+ }
+ return o.String()
+}
+
+// ApplyBoldForegroundGrad renders a given string with a horizontal gradient
+// foreground.
+func ApplyBoldForegroundGrad(t *Styles, input string, color1, color2 color.Color) string {
+ if input == "" {
+ return ""
+ }
+ var o strings.Builder
+ clusters := ForegroundGrad(t, input, true, color1, color2)
+ for _, c := range clusters {
+ fmt.Fprint(&o, c)
+ }
+ 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 {
+ if len(stops) < 2 {
+ return nil
+ }
+
+ stopsPrime := make([]colorful.Color, len(stops))
+ for i, k := range stops {
+ stopsPrime[i], _ = colorful.MakeColor(k)
+ }
+
+ numSegments := len(stopsPrime) - 1
+ blended := make([]color.Color, 0, size)
+
+ // Calculate how many colors each segment should have.
+ segmentSizes := make([]int, numSegments)
+ baseSize := size / numSegments
+ remainder := size % numSegments
+
+ // Distribute the remainder across segments.
+ for i := range numSegments {
+ segmentSizes[i] = baseSize
+ if i < remainder {
+ segmentSizes[i]++
+ }
+ }
+
+ // Generate colors for each segment.
+ for i := range numSegments {
+ c1 := stopsPrime[i]
+ c2 := stopsPrime[i+1]
+ segmentSize := segmentSizes[i]
+
+ for j := range segmentSize {
+ var t float64
+ if segmentSize > 1 {
+ t = float64(j) / float64(segmentSize-1)
+ }
+ c := c1.BlendHcl(c2, t)
+ blended = append(blended, c)
+ }
+ }
+
+ return blended
+}
@@ -256,6 +256,27 @@ type Styles struct {
AgentTaskTag lipgloss.Style // Agent task tag (blue background, bold)
AgentPrompt lipgloss.Style // Agent prompt text
}
+
+ // Dialog styles
+ Dialog struct {
+ Title lipgloss.Style
+ // View is the main content area style.
+ View lipgloss.Style
+ // HelpView is the line that contains the help.
+ HelpView lipgloss.Style
+ Help struct {
+ Ellipsis lipgloss.Style
+ ShortKey lipgloss.Style
+ ShortDesc lipgloss.Style
+ ShortSeparator lipgloss.Style
+ FullKey lipgloss.Style
+ FullDesc lipgloss.Style
+ FullSeparator lipgloss.Style
+ }
+ NormalItem lipgloss.Style
+ SelectedItem lipgloss.Style
+ InputPrompt lipgloss.Style
+ }
}
// ChromaTheme converts the current markdown chroma styles to a chroma
@@ -298,6 +319,12 @@ func (s *Styles) ChromaTheme() chroma.StyleEntries {
}
}
+// DialogHelpStyles returns the styles for dialog help.
+func (s *Styles) DialogHelpStyles() help.Styles {
+ return help.Styles(s.Dialog.Help)
+}
+
+// DefaultStyles returns the default styles for the UI.
func DefaultStyles() Styles {
var (
primary = charmtone.Charple
@@ -394,7 +421,7 @@ func DefaultStyles() Styles {
},
Cursor: textinput.CursorStyle{
Color: secondary,
- Shape: tea.CursorBar,
+ Shape: tea.CursorBlock,
Blink: true,
},
}
@@ -420,7 +447,7 @@ func DefaultStyles() Styles {
},
Cursor: textarea.CursorStyle{
Color: secondary,
- Shape: tea.CursorBar,
+ Shape: tea.CursorBlock,
Blink: true,
},
}
@@ -865,6 +892,21 @@ func DefaultStyles() Styles {
// Text selection.
s.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple)
+ // Dialog styles
+ s.Dialog.Title = base.Padding(0, 1).Foreground(primary)
+ s.Dialog.View = base.Border(lipgloss.RoundedBorder()).BorderForeground(borderFocus)
+ s.Dialog.HelpView = base.Padding(0, 1).AlignHorizontal(lipgloss.Left)
+ s.Dialog.Help.ShortKey = base.Foreground(fgMuted)
+ s.Dialog.Help.ShortDesc = base.Foreground(fgSubtle)
+ s.Dialog.Help.ShortSeparator = base.Foreground(border)
+ s.Dialog.Help.Ellipsis = base.Foreground(border)
+ s.Dialog.Help.FullKey = base.Foreground(fgMuted)
+ s.Dialog.Help.FullDesc = base.Foreground(fgSubtle)
+ s.Dialog.Help.FullSeparator = base.Foreground(border)
+ s.Dialog.NormalItem = base.Padding(0, 1).Foreground(fgBase)
+ s.Dialog.SelectedItem = base.Padding(0, 1).Background(primary).Foreground(fgBase)
+ s.Dialog.InputPrompt = base.Padding(0, 1)
+
return s
}