on main help sign in
feat(ui): new session selector dialog
commit819c33d24e6753836ddfe137596842316f17bc4f authorAyman Bagabas <ayman.bagabas@gmail.com> dateFri 12 Dec 2025, 15:24 (1 year ago) parent18ee540
11 files changed+728−12
go.mod+1−1 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+30−9 internal/ui/styles/grad.go+117 internal/ui/styles/styles.go+44−2
go.mod+1−1
@@ -31,6 +31,7 @@ require (
3131 github.com/charmbracelet/x/term v0.2.2
3232 github.com/denisbrodbeck/machineid v1.0.1
3333 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
34+ github.com/dustin/go-humanize v1.0.1
3435 github.com/google/uuid v1.6.0
3536 github.com/invopop/jsonschema v0.13.0
3637 github.com/joho/godotenv v1.5.1
@@ -101,7 +102,6 @@ require (
101102 github.com/davecgh/go-spew v1.1.1 // indirect
102103 github.com/disintegration/gift v1.1.2 // indirect
103104 github.com/dlclark/regexp2 v1.11.5 // indirect
104− github.com/dustin/go-humanize v1.0.1 // indirect
105105 github.com/felixge/httpsnoop v1.0.4 // indirect
106106 github.com/fsnotify/fsnotify v1.9.0 // indirect
107107 github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
internal/ui/common/elements.go+14
@@ -144,3 +144,17 @@ func Section(t *styles.Styles, text string, width int) string {
144144 }
145145 return text
146146}
147+
148+// DialogTitle renders a dialog title with a decorative line filling the
149+// remaining width.
150+func DialogTitle(t *styles.Styles, title string, width int) string {
151+ char := "╱"
152+ length := lipgloss.Width(title) + 1
153+ remainingWidth := width - length
154+ if remainingWidth > 0 {
155+ lines := strings.Repeat(char, remainingWidth)
156+ lines = styles.ApplyForegroundGrad(t, lines, t.Primary, t.Secondary)
157+ title = title + " " + lines
158+ }
159+ return title
160+}
internal/ui/dialog/dialog.go+15
@@ -25,6 +25,8 @@ const (
2525 ActionNone ActionType = iota
2626 // ActionClose indicates that the dialog should be closed.
2727 ActionClose
28+ // ActionSelect indicates that an item has been selected.
29+ ActionSelect
2830)
2931
3032// Action represents an action taken by a dialog.
@@ -81,6 +83,16 @@ func (d *Overlay) AddDialog(dialog Dialog) {
8183 d.dialogs = append(d.dialogs, dialog)
8284}
8385
86+// RemoveDialog removes the dialog with the specified ID from the stack.
87+func (d *Overlay) RemoveDialog(dialogID string) {
88+ for i, dialog := range d.dialogs {
89+ if dialog.ID() == dialogID {
90+ d.removeDialog(i)
91+ return
92+ }
93+ }
94+}
95+
8496// BringToFront brings the dialog with the specified ID to the front.
8597func (d *Overlay) BringToFront(dialogID string) {
8698 for i, dialog := range d.dialogs {
@@ -116,6 +128,9 @@ func (d *Overlay) Update(msg tea.Msg) (*Overlay, tea.Cmd) {
116128 // Close the current dialog
117129 d.removeDialog(idx)
118130 return d, cmd
131+ case ActionSelect:
132+ // Pass the action up (without modifying the dialog stack)
133+ return d, cmd
119134 }
120135
121136 return d, cmd
internal/ui/dialog/items.gonew file+156
@@ -0,0 +1,156 @@
1+package dialog
2+
3+import (
4+ "strings"
5+ "time"
6+
7+ "charm.land/lipgloss/v2"
8+ "github.com/charmbracelet/crush/internal/session"
9+ "github.com/charmbracelet/crush/internal/ui/list"
10+ "github.com/charmbracelet/crush/internal/ui/styles"
11+ "github.com/charmbracelet/x/ansi"
12+ "github.com/dustin/go-humanize"
13+ "github.com/rivo/uniseg"
14+ "github.com/sahilm/fuzzy"
15+)
16+
17+// ListItem represents a selectable and searchable item in a dialog list.
18+type ListItem interface {
19+ list.FilterableItem
20+ list.FocusStylable
21+ list.MatchSettable
22+
23+ // ID returns the unique identifier of the item.
24+ ID() string
25+}
26+
27+// SessionItem wraps a [session.Session] to implement the [ListItem] interface.
28+type SessionItem struct {
29+ session.Session
30+ t *styles.Styles
31+ m fuzzy.Match
32+}
33+
34+var _ ListItem = &SessionItem{}
35+
36+// Filter returns the filterable value of the session.
37+func (s *SessionItem) Filter() string {
38+ return s.Session.Title
39+}
40+
41+// ID returns the unique identifier of the session.
42+func (s *SessionItem) ID() string {
43+ return s.Session.ID
44+}
45+
46+// SetMatch sets the fuzzy match for the session item.
47+func (s *SessionItem) SetMatch(m fuzzy.Match) {
48+ s.m = m
49+}
50+
51+// Render returns the string representation of the session item.
52+func (s *SessionItem) Render(width int) string {
53+ age := humanize.Time(time.Unix(s.Session.UpdatedAt, 0))
54+ age = s.t.Subtle.Render(age)
55+ age = " " + age
56+ ageLen := lipgloss.Width(age)
57+ title := s.Session.Title
58+ titleLen := lipgloss.Width(title)
59+ title = ansi.Truncate(title, max(0, width-ageLen), "…")
60+ right := lipgloss.NewStyle().AlignHorizontal(lipgloss.Right).Width(width - titleLen).Render(age)
61+
62+ if matches := len(s.m.MatchedIndexes); matches > 0 {
63+ var lastPos int
64+ parts := make([]string, 0)
65+ // TODO: Use [ansi.Style].Underline true/false to underline only the
66+ // matched parts instead of using [lipgloss.StyleRanges] since it can
67+ // be cheaper with less allocations.
68+ ranges := matchedRanges(s.m.MatchedIndexes)
69+ for _, rng := range ranges {
70+ start, stop := bytePosToVisibleCharPos(title, rng)
71+ if start > lastPos {
72+ parts = append(parts, title[lastPos:start])
73+ }
74+ // NOTE: We're using [ansi.Style] here instead of [lipgloss.Style]
75+ // because we can control the underline start and stop more
76+ // precisely via [ansi.AttrUnderline] and [ansi.AttrNoUnderline]
77+ // which only affect the underline attribute without interfering
78+ // with other styles.
79+ parts = append(parts,
80+ ansi.NewStyle().Underline(true).String(),
81+ title[start:stop+1],
82+ ansi.NewStyle().Underline(false).String(),
83+ )
84+ lastPos = stop + 1
85+ }
86+ if lastPos < len(title) {
87+ parts = append(parts, title[lastPos:])
88+ }
89+ return strings.Join(parts, "") + right
90+ }
91+ return title + right
92+}
93+
94+// FocusStyle returns the style to be applied when the item is focused.
95+func (s *SessionItem) FocusStyle() lipgloss.Style {
96+ return s.t.Dialog.SelectedItem
97+}
98+
99+// BlurStyle returns the style to be applied when the item is blurred.
100+func (s *SessionItem) BlurStyle() lipgloss.Style {
101+ return s.t.Dialog.NormalItem
102+}
103+
104+// sessionItems takes a slice of [session.Session]s and convert them to a slice
105+// of [ListItem]s.
106+func sessionItems(t *styles.Styles, sessions ...session.Session) []list.FilterableItem {
107+ items := make([]list.FilterableItem, len(sessions))
108+ for i, s := range sessions {
109+ items[i] = &SessionItem{Session: s, t: t}
110+ }
111+ return items
112+}
113+
114+func matchedRanges(in []int) [][2]int {
115+ if len(in) == 0 {
116+ return [][2]int{}
117+ }
118+ current := [2]int{in[0], in[0]}
119+ if len(in) == 1 {
120+ return [][2]int{current}
121+ }
122+ var out [][2]int
123+ for i := 1; i < len(in); i++ {
124+ if in[i] == current[1]+1 {
125+ current[1] = in[i]
126+ } else {
127+ out = append(out, current)
128+ current = [2]int{in[i], in[i]}
129+ }
130+ }
131+ out = append(out, current)
132+ return out
133+}
134+
135+func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
136+ bytePos, byteStart, byteStop := 0, rng[0], rng[1]
137+ pos, start, stop := 0, 0, 0
138+ gr := uniseg.NewGraphemes(str)
139+ for byteStart > bytePos {
140+ if !gr.Next() {
141+ break
142+ }
143+ bytePos += len(gr.Str())
144+ pos += max(1, gr.Width())
145+ }
146+ start = pos
147+ for byteStop > bytePos {
148+ if !gr.Next() {
149+ break
150+ }
151+ bytePos += len(gr.Str())
152+ pos += max(1, gr.Width())
153+ }
154+ stop = pos
155+ return start, stop
156+}
internal/ui/dialog/sessions.gonew file+196
@@ -0,0 +1,196 @@
1+package dialog
2+
3+import (
4+ "strings"
5+
6+ "charm.land/bubbles/v2/help"
7+ "charm.land/bubbles/v2/key"
8+ "charm.land/bubbles/v2/textinput"
9+ tea "charm.land/bubbletea/v2"
10+ "charm.land/lipgloss/v2"
11+ "github.com/charmbracelet/crush/internal/session"
12+ "github.com/charmbracelet/crush/internal/ui/common"
13+ "github.com/charmbracelet/crush/internal/ui/list"
14+)
15+
16+// SessionDialogID is the identifier for the session selector dialog.
17+const SessionDialogID = "session"
18+
19+// Session is a session selector dialog.
20+type Session struct {
21+ width, height int
22+ com *common.Common
23+ help help.Model
24+ list *list.FilterableList
25+ input textinput.Model
26+
27+ keyMap struct {
28+ Select key.Binding
29+ Next key.Binding
30+ Previous key.Binding
31+ Close key.Binding
32+ }
33+}
34+
35+var _ Dialog = (*Session)(nil)
36+
37+// SessionSelectedMsg is a message sent when a session is selected.
38+type SessionSelectedMsg struct {
39+ Session session.Session
40+}
41+
42+// NewSessions creates a new Session dialog.
43+func NewSessions(com *common.Common, sessions ...session.Session) *Session {
44+ s := new(Session)
45+ s.com = com
46+ help := help.New()
47+ help.Styles = com.Styles.DialogHelpStyles()
48+
49+ s.help = help
50+ s.list = list.NewFilterableList(sessionItems(com.Styles, sessions...)...)
51+ s.list.Focus()
52+ s.list.SetSelected(0)
53+
54+ s.input = textinput.New()
55+ s.input.SetVirtualCursor(false)
56+ s.input.Placeholder = "Enter session name"
57+ s.input.SetStyles(com.Styles.TextInput)
58+ s.input.Focus()
59+
60+ s.keyMap.Select = key.NewBinding(
61+ key.WithKeys("enter", "tab", "ctrl+y"),
62+ key.WithHelp("enter", "choose"),
63+ )
64+ s.keyMap.Next = key.NewBinding(
65+ key.WithKeys("down", "ctrl+n"),
66+ key.WithHelp("↓", "next item"),
67+ )
68+ s.keyMap.Previous = key.NewBinding(
69+ key.WithKeys("up", "ctrl+p"),
70+ key.WithHelp("↑", "previous item"),
71+ )
72+ s.keyMap.Close = CloseKey
73+ return s
74+}
75+
76+// Cursor returns the cursor position relative to the dialog.
77+func (s *Session) Cursor() *tea.Cursor {
78+ return s.input.Cursor()
79+}
80+
81+// SetSize sets the size of the dialog.
82+func (s *Session) SetSize(width, height int) {
83+ s.width = width
84+ s.height = height
85+ innerWidth := width - s.com.Styles.Dialog.View.GetHorizontalFrameSize()
86+ s.input.SetWidth(innerWidth - s.com.Styles.Dialog.InputPrompt.GetHorizontalFrameSize() - 1)
87+ s.list.SetSize(innerWidth, height-6) // (1) title + (3) input + (1) padding + (1) help
88+ s.help.SetWidth(width)
89+}
90+
91+// SelectedItem returns the currently selected item. It may be nil if no item
92+// is selected.
93+func (s *Session) SelectedItem() list.Item {
94+ return s.list.SelectedItem()
95+}
96+
97+// ID implements Dialog.
98+func (s *Session) ID() string {
99+ return SessionDialogID
100+}
101+
102+// Update implements Dialog.
103+func (s *Session) Update(msg tea.Msg) (Action, tea.Cmd) {
104+ switch msg := msg.(type) {
105+ case tea.KeyPressMsg:
106+ switch {
107+ case key.Matches(msg, s.keyMap.Previous):
108+ s.list.Focus()
109+ s.list.SelectPrev()
110+ s.list.ScrollToSelected()
111+ case key.Matches(msg, s.keyMap.Next):
112+ s.list.Focus()
113+ s.list.SelectNext()
114+ s.list.ScrollToSelected()
115+ case key.Matches(msg, s.keyMap.Select):
116+ if item := s.list.SelectedItem(); item != nil {
117+ sessionItem := item.(*SessionItem)
118+ return Action{Type: ActionSelect, Payload: sessionItem.Session}, SessionSelectCmd(sessionItem.Session)
119+ }
120+ default:
121+ var cmd tea.Cmd
122+ s.input, cmd = s.input.Update(msg)
123+ s.list.SetFilter(s.input.Value())
124+ return Action{}, cmd
125+ }
126+ }
127+ return Action{}, nil
128+}
129+
130+// Layer implements Dialog.
131+func (s *Session) Layer() *lipgloss.Layer {
132+ titleStyle := s.com.Styles.Dialog.Title
133+ helpStyle := s.com.Styles.Dialog.HelpView
134+ dialogStyle := s.com.Styles.Dialog.View.Width(s.width)
135+ inputStyle := s.com.Styles.Dialog.InputPrompt
136+ helpStyle = helpStyle.Width(s.width - dialogStyle.GetHorizontalFrameSize())
137+ listContent := s.list.Render()
138+ if nlines := lipgloss.Height(listContent); nlines < s.list.Height() {
139+ // pad the list content to avoid jumping when navigating
140+ listContent += strings.Repeat("\n", max(0, s.list.Height()-nlines))
141+ }
142+
143+ content := strings.Join([]string{
144+ titleStyle.Render(
145+ common.DialogTitle(
146+ s.com.Styles,
147+ "Switch Session",
148+ max(0, s.width-
149+ dialogStyle.GetHorizontalFrameSize()-
150+ titleStyle.GetHorizontalFrameSize()))),
151+ "",
152+ inputStyle.Render(s.input.View()),
153+ "",
154+ listContent,
155+ "",
156+ helpStyle.Render(s.help.View(s)),
157+ }, "\n")
158+
159+ return lipgloss.NewLayer(dialogStyle.Render(content))
160+}
161+
162+// ShortHelp implements [help.KeyMap].
163+func (s *Session) ShortHelp() []key.Binding {
164+ updown := key.NewBinding(
165+ key.WithKeys("down", "up"),
166+ key.WithHelp("↑↓", "choose"),
167+ )
168+ return []key.Binding{
169+ updown,
170+ s.keyMap.Select,
171+ s.keyMap.Close,
172+ }
173+}
174+
175+// FullHelp implements [help.KeyMap].
176+func (s *Session) FullHelp() [][]key.Binding {
177+ m := [][]key.Binding{}
178+ slice := []key.Binding{
179+ s.keyMap.Select,
180+ s.keyMap.Next,
181+ s.keyMap.Previous,
182+ s.keyMap.Close,
183+ }
184+ for i := 0; i < len(slice); i += 4 {
185+ end := min(i+4, len(slice))
186+ m = append(m, slice[i:end])
187+ }
188+ return m
189+}
190+
191+// SessionSelectCmd creates a command that sends a SessionSelectMsg.
192+func SessionSelectCmd(s session.Session) tea.Cmd {
193+ return func() tea.Msg {
194+ return SessionSelectedMsg{Session: s}
195+ }
196+}
internal/ui/list/filterable.gonew file+118
@@ -0,0 +1,118 @@
1+package list
2+
3+import (
4+ "github.com/sahilm/fuzzy"
5+)
6+
7+// FilterableItem is an item that can be filtered via a query.
8+type FilterableItem interface {
9+ Item
10+ // Filter returns the value to be used for filtering.
11+ Filter() string
12+}
13+
14+// MatchSettable is an interface for items that can have their match indexes
15+// and match score set.
16+type MatchSettable interface {
17+ SetMatch(fuzzy.Match)
18+}
19+
20+// FilterableList is a list that takes filterable items that can be filtered
21+// via a settable query.
22+type FilterableList struct {
23+ *List
24+ items []FilterableItem
25+ query string
26+}
27+
28+// NewFilterableList creates a new filterable list.
29+func NewFilterableList(items ...FilterableItem) *FilterableList {
30+ f := &FilterableList{
31+ List: NewList(),
32+ items: items,
33+ }
34+ f.SetItems(items...)
35+ return f
36+}
37+
38+// SetItems sets the list items and updates the filtered items.
39+func (f *FilterableList) SetItems(items ...FilterableItem) {
40+ f.items = items
41+ fitems := make([]Item, len(items))
42+ for i, item := range items {
43+ fitems[i] = item
44+ }
45+ f.List.SetItems(fitems...)
46+}
47+
48+// AppendItems appends items to the list and updates the filtered items.
49+func (f *FilterableList) AppendItems(items ...FilterableItem) {
50+ f.items = append(f.items, items...)
51+ itms := make([]Item, len(f.items))
52+ for i, item := range f.items {
53+ itms[i] = item
54+ }
55+ f.List.SetItems(itms...)
56+}
57+
58+// PrependItems prepends items to the list and updates the filtered items.
59+func (f *FilterableList) PrependItems(items ...FilterableItem) {
60+ f.items = append(items, f.items...)
61+ itms := make([]Item, len(f.items))
62+ for i, item := range f.items {
63+ itms[i] = item
64+ }
65+ f.List.SetItems(itms...)
66+}
67+
68+// SetFilter sets the filter query and updates the list items.
69+func (f *FilterableList) SetFilter(q string) {
70+ f.query = q
71+}
72+
73+type filterableItems []FilterableItem
74+
75+func (f filterableItems) Len() int {
76+ return len(f)
77+}
78+
79+func (f filterableItems) String(i int) string {
80+ return f[i].Filter()
81+}
82+
83+// VisibleItems returns the visible items after filtering.
84+func (f *FilterableList) VisibleItems() []Item {
85+ if f.query == "" {
86+ items := make([]Item, len(f.items))
87+ for i, item := range f.items {
88+ if ms, ok := item.(MatchSettable); ok {
89+ ms.SetMatch(fuzzy.Match{})
90+ item = ms.(FilterableItem)
91+ }
92+ items[i] = item
93+ }
94+ return items
95+ }
96+
97+ items := filterableItems(f.items)
98+ matches := fuzzy.FindFrom(f.query, items)
99+ matchedItems := []Item{}
100+ resultSize := len(matches)
101+ for i := range resultSize {
102+ match := matches[i]
103+ item := items[match.Index]
104+ if ms, ok := item.(MatchSettable); ok {
105+ ms.SetMatch(match)
106+ item = ms.(FilterableItem)
107+ }
108+ matchedItems = append(matchedItems, item)
109+ }
110+
111+ return matchedItems
112+}
113+
114+// Render renders the filterable list.
115+func (f *FilterableList) Render() string {
116+ f.List.SetItems(f.VisibleItems()...)
117+ return f.List.Render()
118+}
internal/ui/list/list.go+27
@@ -162,6 +162,7 @@ func (l *List) renderItem(idx int, process bool) renderedItem {
162162 if !ok {
163163 item := l.items[idx]
164164 rendered := item.Render(l.width - style.GetHorizontalFrameSize())
165+ rendered = strings.TrimRight(rendered, "\n")
165166 height := countLines(rendered)
166167
167168 ri = renderedItem{
@@ -387,6 +388,23 @@ func (l *List) PrependItems(items ...Item) {
387388 }
388389}
389390
391+// SetItems sets the items in the list.
392+func (l *List) SetItems(items ...Item) {
393+ l.setItems(true, items...)
394+}
395+
396+// setItems sets the items in the list. If evict is true, it clears the
397+// rendered item cache.
398+func (l *List) setItems(evict bool, items ...Item) {
399+ l.items = items
400+ if evict {
401+ l.renderedItems = make(map[int]renderedItem)
402+ }
403+ l.selectedIdx = min(l.selectedIdx, len(l.items)-1)
404+ l.offsetIdx = min(l.offsetIdx, len(l.items)-1)
405+ l.offsetLine = 0
406+}
407+
390408// AppendItems appends items to the list.
391409func (l *List) AppendItems(items ...Item) {
392410 l.items = append(l.items, items...)
@@ -514,6 +532,15 @@ func (l *List) SelectLast() {
514532 }
515533}
516534
535+// SelectedItem returns the currently selected item. It may be nil if no item
536+// is selected.
537+func (l *List) SelectedItem() Item {
538+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
539+ return nil
540+ }
541+ return l.items[l.selectedIdx]
542+}
543+
517544// SelectFirstInView selects the first item currently in view.
518545func (l *List) SelectFirstInView() {
519546 startIdx, _ := l.findVisibleItems()
internal/ui/model/chat.go+10
@@ -50,6 +50,16 @@ func (m *Chat) PrependItems(items ...list.Item) {
5050 m.list.ScrollToIndex(0)
5151}
5252
53+// SetMessages sets the chat messages to the provided list of message items.
54+func (m *Chat) SetMessages(msgs ...MessageItem) {
55+ items := make([]list.Item, len(msgs))
56+ for i, msg := range msgs {
57+ items[i] = msg
58+ }
59+ m.list.SetItems(items...)
60+ m.list.ScrollToBottom()
61+}
62+
5363// AppendMessages appends a new message item to the chat list.
5464func (m *Chat) AppendMessages(msgs ...MessageItem) {
5565 items := make([]list.Item, len(msgs))
internal/ui/model/ui.go+30−9
@@ -50,6 +50,11 @@ const (
5050 uiChatCompact
5151)
5252
53+// sessionsLoadedMsg is a message indicating that sessions have been loaded.
54+type sessionsLoadedMsg struct {
55+ sessions []session.Session
56+}
57+
5358type sessionLoadedMsg struct {
5459 sess session.Session
5560}
@@ -167,13 +172,6 @@ func (m *UI) Init() tea.Cmd {
167172 if m.QueryVersion {
168173 cmds = append(cmds, tea.RequestTerminalVersion)
169174 }
170− allSessions, _ := m.com.App.Sessions.List(context.Background())
171− if len(allSessions) > 0 {
172− cmds = append(cmds, func() tea.Msg {
173− // time.Sleep(2 * time.Second)
174− return m.loadSession(allSessions[0].ID)()
175− })
176− }
177175 return tea.Batch(cmds...)
178176}
179177
@@ -190,6 +188,16 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
190188 if !m.sendProgressBar {
191189 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
192190 }
191+ case sessionsLoadedMsg:
192+ sessions := dialog.NewSessions(m.com, msg.sessions...)
193+ sessions.SetSize(min(120, m.width-8), 30)
194+ m.dialog.AddDialog(sessions)
195+ case dialog.SessionSelectedMsg:
196+ m.dialog.RemoveDialog(dialog.SessionDialogID)
197+ cmds = append(cmds,
198+ m.loadSession(msg.Session.ID),
199+ m.loadSessionFiles(msg.Session.ID),
200+ )
193201 case sessionLoadedMsg:
194202 m.state = uiChat
195203 m.session = &msg.sess
@@ -208,7 +216,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
208216 for _, msg := range msgPtrs {
209217 items = append(items, GetMessageItems(m.com.Styles, msg, toolResultMap)...)
210218 }
211− m.chat.AppendMessages(items...)
219+
220+ m.chat.SetMessages(items...)
212221
213222 // Notify that session loading is done to scroll to bottom. This is
214223 // 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) {
361370 case key.Matches(msg, m.keyMap.Models):
362371 // TODO: Implement me
363372 case key.Matches(msg, m.keyMap.Sessions):
364− // TODO: Implement me
373+ if m.dialog.ContainsDialog(dialog.SessionDialogID) {
374+ // Bring to front
375+ m.dialog.BringToFront(dialog.SessionDialogID)
376+ } else {
377+ cmds = append(cmds, m.loadSessionsCmd)
378+ }
365379 return true
366380 }
367381 return false
@@ -955,6 +969,13 @@ func (m *UI) renderSidebarLogo(width int) {
955969 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
956970}
957971
972+// loadSessionsCmd loads the list of sessions and returns a command that sends
973+// a sessionFilesLoadedMsg when done.
974+func (m *UI) loadSessionsCmd() tea.Msg {
975+ allSessions, _ := m.com.App.Sessions.List(context.TODO())
976+ return sessionsLoadedMsg{sessions: allSessions}
977+}
978+
958979// renderLogo renders the Crush logo with the given styles and dimensions.
959980func renderLogo(t *styles.Styles, compact bool, width int) string {
960981 return logo.Render(version.Version, compact, logo.Opts{
internal/ui/styles/grad.gonew file+117
@@ -0,0 +1,117 @@
1+package styles
2+
3+import (
4+ "fmt"
5+ "image/color"
6+ "strings"
7+
8+ "github.com/lucasb-eyer/go-colorful"
9+ "github.com/rivo/uniseg"
10+)
11+
12+// ForegroundGrad returns a slice of strings representing the input string
13+// rendered with a horizontal gradient foreground from color1 to color2. Each
14+// string in the returned slice corresponds to a grapheme cluster in the input
15+// string. If bold is true, the rendered strings will be bolded.
16+func ForegroundGrad(t *Styles, input string, bold bool, color1, color2 color.Color) []string {
17+ if input == "" {
18+ return []string{""}
19+ }
20+ if len(input) == 1 {
21+ style := t.Base.Foreground(color1)
22+ if bold {
23+ style.Bold(true)
24+ }
25+ return []string{style.Render(input)}
26+ }
27+ var clusters []string
28+ gr := uniseg.NewGraphemes(input)
29+ for gr.Next() {
30+ clusters = append(clusters, string(gr.Runes()))
31+ }
32+
33+ ramp := blendColors(len(clusters), color1, color2)
34+ for i, c := range ramp {
35+ style := t.Base.Foreground(c)
36+ if bold {
37+ style.Bold(true)
38+ }
39+ clusters[i] = style.Render(clusters[i])
40+ }
41+ return clusters
42+}
43+
44+// ApplyForegroundGrad renders a given string with a horizontal gradient
45+// foreground.
46+func ApplyForegroundGrad(t *Styles, input string, color1, color2 color.Color) string {
47+ if input == "" {
48+ return ""
49+ }
50+ var o strings.Builder
51+ clusters := ForegroundGrad(t, input, false, color1, color2)
52+ for _, c := range clusters {
53+ fmt.Fprint(&o, c)
54+ }
55+ return o.String()
56+}
57+
58+// ApplyBoldForegroundGrad renders a given string with a horizontal gradient
59+// foreground.
60+func ApplyBoldForegroundGrad(t *Styles, input string, color1, color2 color.Color) string {
61+ if input == "" {
62+ return ""
63+ }
64+ var o strings.Builder
65+ clusters := ForegroundGrad(t, input, true, color1, color2)
66+ for _, c := range clusters {
67+ fmt.Fprint(&o, c)
68+ }
69+ return o.String()
70+}
71+
72+// blendColors returns a slice of colors blended between the given keys.
73+// Blending is done in Hcl to stay in gamut.
74+func blendColors(size int, stops ...color.Color) []color.Color {
75+ if len(stops) < 2 {
76+ return nil
77+ }
78+
79+ stopsPrime := make([]colorful.Color, len(stops))
80+ for i, k := range stops {
81+ stopsPrime[i], _ = colorful.MakeColor(k)
82+ }
83+
84+ numSegments := len(stopsPrime) - 1
85+ blended := make([]color.Color, 0, size)
86+
87+ // Calculate how many colors each segment should have.
88+ segmentSizes := make([]int, numSegments)
89+ baseSize := size / numSegments
90+ remainder := size % numSegments
91+
92+ // Distribute the remainder across segments.
93+ for i := range numSegments {
94+ segmentSizes[i] = baseSize
95+ if i < remainder {
96+ segmentSizes[i]++
97+ }
98+ }
99+
100+ // Generate colors for each segment.
101+ for i := range numSegments {
102+ c1 := stopsPrime[i]
103+ c2 := stopsPrime[i+1]
104+ segmentSize := segmentSizes[i]
105+
106+ for j := range segmentSize {
107+ var t float64
108+ if segmentSize > 1 {
109+ t = float64(j) / float64(segmentSize-1)
110+ }
111+ c := c1.BlendHcl(c2, t)
112+ blended = append(blended, c)
113+ }
114+ }
115+
116+ return blended
117+}
internal/ui/styles/styles.go+44−2
@@ -256,6 +256,27 @@ type Styles struct {
256256 AgentTaskTag lipgloss.Style // Agent task tag (blue background, bold)
257257 AgentPrompt lipgloss.Style // Agent prompt text
258258 }
259+
260+ // Dialog styles
261+ Dialog struct {
262+ Title lipgloss.Style
263+ // View is the main content area style.
264+ View lipgloss.Style
265+ // HelpView is the line that contains the help.
266+ HelpView lipgloss.Style
267+ Help struct {
268+ Ellipsis lipgloss.Style
269+ ShortKey lipgloss.Style
270+ ShortDesc lipgloss.Style
271+ ShortSeparator lipgloss.Style
272+ FullKey lipgloss.Style
273+ FullDesc lipgloss.Style
274+ FullSeparator lipgloss.Style
275+ }
276+ NormalItem lipgloss.Style
277+ SelectedItem lipgloss.Style
278+ InputPrompt lipgloss.Style
279+ }
259280}
260281
261282// ChromaTheme converts the current markdown chroma styles to a chroma
@@ -298,6 +319,12 @@ func (s *Styles) ChromaTheme() chroma.StyleEntries {
298319 }
299320}
300321
322+// DialogHelpStyles returns the styles for dialog help.
323+func (s *Styles) DialogHelpStyles() help.Styles {
324+ return help.Styles(s.Dialog.Help)
325+}
326+
327+// DefaultStyles returns the default styles for the UI.
301328func DefaultStyles() Styles {
302329 var (
303330 primary = charmtone.Charple
@@ -394,7 +421,7 @@ func DefaultStyles() Styles {
394421 },
395422 Cursor: textinput.CursorStyle{
396423 Color: secondary,
397− Shape: tea.CursorBar,
424+ Shape: tea.CursorBlock,
398425 Blink: true,
399426 },
400427 }
@@ -420,7 +447,7 @@ func DefaultStyles() Styles {
420447 },
421448 Cursor: textarea.CursorStyle{
422449 Color: secondary,
423− Shape: tea.CursorBar,
450+ Shape: tea.CursorBlock,
424451 Blink: true,
425452 },
426453 }
@@ -865,6 +892,21 @@ func DefaultStyles() Styles {
865892 // Text selection.
866893 s.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple)
867894
895+ // Dialog styles
896+ s.Dialog.Title = base.Padding(0, 1).Foreground(primary)
897+ s.Dialog.View = base.Border(lipgloss.RoundedBorder()).BorderForeground(borderFocus)
898+ s.Dialog.HelpView = base.Padding(0, 1).AlignHorizontal(lipgloss.Left)
899+ s.Dialog.Help.ShortKey = base.Foreground(fgMuted)
900+ s.Dialog.Help.ShortDesc = base.Foreground(fgSubtle)
901+ s.Dialog.Help.ShortSeparator = base.Foreground(border)
902+ s.Dialog.Help.Ellipsis = base.Foreground(border)
903+ s.Dialog.Help.FullKey = base.Foreground(fgMuted)
904+ s.Dialog.Help.FullDesc = base.Foreground(fgSubtle)
905+ s.Dialog.Help.FullSeparator = base.Foreground(border)
906+ s.Dialog.NormalItem = base.Padding(0, 1).Foreground(fgBase)
907+ s.Dialog.SelectedItem = base.Padding(0, 1).Background(primary).Foreground(fgBase)
908+ s.Dialog.InputPrompt = base.Padding(0, 1)
909+
868910 return s
869911}
870912