From 819c33d24e6753836ddfe137596842316f17bc4f Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 12 Dec 2025 15:24:45 -0500 Subject: [PATCH] feat(ui): new session selector dialog --- 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(-) create mode 100644 internal/ui/dialog/items.go create mode 100644 internal/ui/dialog/sessions.go create mode 100644 internal/ui/list/filterable.go create mode 100644 internal/ui/styles/grad.go diff --git a/go.mod b/go.mod index a5ca5776567e594689af6aacb33815a4b5edfe6b..0b87cc171a5c1ebda3aee445289f7820c2807a21 100644 --- a/go.mod +++ b/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 diff --git a/internal/ui/common/elements.go b/internal/ui/common/elements.go index 246543078028f5ab616c88c5b1b75103489e1d1a..1475d123a514b75b930b9479ec59f5e5d8a19cf3 100644 --- a/internal/ui/common/elements.go +++ b/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 +} diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index 046e73aa6b6466fe179f6358fc23a93774b5ca74..73c25151cf7edab317c3c52dd064bc1399598bd6 100644 --- a/internal/ui/dialog/dialog.go +++ b/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 diff --git a/internal/ui/dialog/items.go b/internal/ui/dialog/items.go new file mode 100644 index 0000000000000000000000000000000000000000..eb0bfc727d3322f0e928d36069b9d483fc130df5 --- /dev/null +++ b/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 +} diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go new file mode 100644 index 0000000000000000000000000000000000000000..c526268cbb3d09509d3e086fcc73e557d272b3dd --- /dev/null +++ b/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} + } +} diff --git a/internal/ui/list/filterable.go b/internal/ui/list/filterable.go new file mode 100644 index 0000000000000000000000000000000000000000..c45db41da2cc6be8bd61fba57818e5a7d902f5cd --- /dev/null +++ b/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() +} diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index a86dc97d1af0fe6c938980c3a3aa65bf046ebebf..92585c208f16d91d5c8c3e9f7d8f5f28c4721f9c 100644 --- a/internal/ui/list/list.go +++ b/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() diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 4acf84cabf995275287221971fae5775e363bf9f..562bd2cc79824c5801b57d11d9570344c3a39317 100644 --- a/internal/ui/model/chat.go +++ b/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)) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index be06824d42e934f158370e8dd5167bb6e30cbe3f..9fe351283decaeecfedec63ff88c8ddbc5e98b4a 100644 --- a/internal/ui/model/ui.go +++ b/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{ diff --git a/internal/ui/styles/grad.go b/internal/ui/styles/grad.go new file mode 100644 index 0000000000000000000000000000000000000000..866a00fa501b48caa2a69f559efd7d45964cec97 --- /dev/null +++ b/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 +} diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 97d3b4950f2672481a949a7538c00fa2579c3065..cd9df36c8a20713649bdade5405db8ecc855c221 100644 --- a/internal/ui/styles/styles.go +++ b/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 }