feat(ui): new session selector dialog

Ayman Bagabas created

Change summary

go.mod                         |   2 
internal/ui/common/elements.go |  14 ++
internal/ui/dialog/dialog.go   |  15 ++
internal/ui/dialog/items.go    | 156 ++++++++++++++++++++++++++++
internal/ui/dialog/sessions.go | 196 ++++++++++++++++++++++++++++++++++++
internal/ui/list/filterable.go | 118 +++++++++++++++++++++
internal/ui/list/list.go       |  27 ++++
internal/ui/model/chat.go      |  10 +
internal/ui/model/ui.go        |  39 +++++-
internal/ui/styles/grad.go     | 117 +++++++++++++++++++++
internal/ui/styles/styles.go   |  46 ++++++++
11 files changed, 728 insertions(+), 12 deletions(-)

Detailed changes

go.mod 🔗

@@ -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

internal/ui/common/elements.go 🔗

@@ -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
+}

internal/ui/dialog/dialog.go 🔗

@@ -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

internal/ui/dialog/items.go 🔗

@@ -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
+}

internal/ui/dialog/sessions.go 🔗

@@ -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}
+	}
+}

internal/ui/list/filterable.go 🔗

@@ -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()
+}

internal/ui/list/list.go 🔗

@@ -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()

internal/ui/model/chat.go 🔗

@@ -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))

internal/ui/model/ui.go 🔗

@@ -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{

internal/ui/styles/grad.go 🔗

@@ -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
+}

internal/ui/styles/styles.go 🔗

@@ -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
 }