on main help sign in
feat: update session title (#1988)

* feat: update session title * chore: implement review requests

commit7154e46becc542a08a56d6149de754fc5873a7c1 authorKujtim Hoxha <kujtimii.h@gmail.com> dateMon 26 Jan 2026, 16:19 (9 months ago) parent0eee061
3 files changed+203−14
internal/ui/dialog/sessions.go+139−8 internal/ui/dialog/sessions_item.go+46−6 internal/ui/styles/styles.go+18
internal/ui/dialog/sessions.go+139−8
@@ -2,11 +2,13 @@ package dialog
22
33import (
44 "context"
5+ "strings"
56
67 "charm.land/bubbles/v2/help"
78 "charm.land/bubbles/v2/key"
89 "charm.land/bubbles/v2/textinput"
910 tea "charm.land/bubbletea/v2"
11+ "charm.land/lipgloss/v2"
1012 "github.com/charmbracelet/crush/internal/session"
1113 "github.com/charmbracelet/crush/internal/ui/common"
1214 "github.com/charmbracelet/crush/internal/ui/list"
@@ -43,6 +45,9 @@ type Session struct {
4345 Previous key.Binding
4446 UpDown key.Binding
4547 Delete key.Binding
48+ Rename key.Binding
49+ ConfirmRename key.Binding
50+ CancelRename key.Binding
4651 ConfirmDelete key.Binding
4752 CancelDelete key.Binding
4853 Close key.Binding
@@ -103,6 +108,18 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error)
103108 key.WithKeys("ctrl+x"),
104109 key.WithHelp("ctrl+x", "delete"),
105110 )
111+ s.keyMap.Rename = key.NewBinding(
112+ key.WithKeys("ctrl+r"),
113+ key.WithHelp("ctrl+r", "rename"),
114+ )
115+ s.keyMap.ConfirmRename = key.NewBinding(
116+ key.WithKeys("enter"),
117+ key.WithHelp("enter", "confirm"),
118+ )
119+ s.keyMap.CancelRename = key.NewBinding(
120+ key.WithKeys("esc"),
121+ key.WithHelp("esc", "cancel"),
122+ )
106123 s.keyMap.ConfirmDelete = key.NewBinding(
107124 key.WithKeys("y"),
108125 key.WithHelp("y", "delete"),
@@ -129,15 +146,40 @@ func (s *Session) HandleMsg(msg tea.Msg) Action {
129146 case sessionsModeDeleting:
130147 switch {
131148 case key.Matches(msg, s.keyMap.ConfirmDelete):
132− return s.confirmDeleteSession()
149+ action := s.confirmDeleteSession()
150+ s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...)
151+ s.list.SelectFirst()
152+ s.list.ScrollToSelected()
153+ return action
133154 case key.Matches(msg, s.keyMap.CancelDelete):
134155 s.sessionsMode = sessionsModeNormal
135156 s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...)
136157 }
158+ case sessionsModeUpdating:
159+ switch {
160+ case key.Matches(msg, s.keyMap.ConfirmRename):
161+ action := s.confirmRenameSession()
162+ s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...)
163+ return action
164+ case key.Matches(msg, s.keyMap.CancelRename):
165+ s.sessionsMode = sessionsModeNormal
166+ s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...)
167+ default:
168+ item := s.list.SelectedItem()
169+ if item == nil {
170+ return nil
171+ }
172+ if sessionItem, ok := item.(*SessionItem); ok {
173+ return sessionItem.HandleInput(msg)
174+ }
175+ }
137176 default:
138177 switch {
139178 case key.Matches(msg, s.keyMap.Close):
140179 return ActionClose{}
180+ case key.Matches(msg, s.keyMap.Rename):
181+ s.sessionsMode = sessionsModeUpdating
182+ s.list.SetItems(sessionItems(s.com.Styles, sessionsModeUpdating, s.sessions...)...)
141183 case key.Matches(msg, s.keyMap.Delete):
142184 if s.isCurrentSessionBusy() {
143185 return ActionCmd{uiutil.ReportWarn("Agent is busy, please wait...")}
@@ -218,6 +260,51 @@ func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
218260 rc.TitleGradientToColor = t.Dialog.Sessions.DeletingTitleGradientToColor
219261 rc.ViewStyle = t.Dialog.Sessions.DeletingView
220262 rc.AddPart(t.Dialog.Sessions.DeletingMessage.Render("Delete this session?"))
263+ case sessionsModeUpdating:
264+ rc.TitleStyle = t.Dialog.Sessions.UpdatingTitle
265+ rc.TitleGradientFromColor = t.Dialog.Sessions.UpdatingTitleGradientFromColor
266+ rc.TitleGradientToColor = t.Dialog.Sessions.UpdatingTitleGradientToColor
267+ rc.ViewStyle = t.Dialog.Sessions.UpdatingView
268+ message := t.Dialog.Sessions.UpdatingMessage.Render("Rename this session?")
269+ rc.AddPart(message)
270+ item := s.selectedSessionItem()
271+ if item == nil {
272+ return nil
273+ }
274+ cur = item.Cursor()
275+ if cur == nil {
276+ break
277+ }
278+
279+ start, end := s.list.VisibleItemIndices()
280+ selectedIndex := s.list.Selected()
281+
282+ titleStyle := t.Dialog.Sessions.UpdatingTitle
283+ dialogStyle := t.Dialog.Sessions.UpdatingView
284+ inputStyle := t.Dialog.InputPrompt
285+
286+ // Adjust cursor position to account for dialog layout + message
287+ cur.X += inputStyle.GetBorderLeftSize() +
288+ inputStyle.GetMarginLeft() +
289+ inputStyle.GetPaddingLeft() +
290+ dialogStyle.GetBorderLeftSize() +
291+ dialogStyle.GetPaddingLeft() +
292+ dialogStyle.GetMarginLeft()
293+ cur.Y += titleStyle.GetVerticalFrameSize() +
294+ inputStyle.GetBorderTopSize() +
295+ inputStyle.GetMarginTop() +
296+ inputStyle.GetPaddingTop() +
297+ inputStyle.GetBorderBottomSize() +
298+ inputStyle.GetMarginBottom() +
299+ inputStyle.GetPaddingBottom() +
300+ dialogStyle.GetPaddingTop() +
301+ dialogStyle.GetBorderTopSize() +
302+ lipgloss.Height(message) - 1
303+
304+ // move the cursor by one down until we see the selectedIndex
305+ for ; start <= end && start != selectedIndex && selectedIndex > -1; start++ {
306+ cur.Y += 1
307+ }
221308 default:
222309 inputView := t.Dialog.InputPrompt.Render(s.input.View())
223310 cur = s.Cursor()
@@ -260,9 +347,6 @@ func (s *Session) removeSession(id string) {
260347 newSessions = append(newSessions, sess)
261348 }
262349 s.sessions = newSessions
263− s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...)
264− s.list.SelectFirst()
265− s.list.ScrollToSelected()
266350}
267351
268352func (s *Session) deleteSessionCmd(id string) tea.Cmd {
@@ -275,6 +359,42 @@ func (s *Session) deleteSessionCmd(id string) tea.Cmd {
275359 }
276360}
277361
362+func (s *Session) confirmRenameSession() Action {
363+ sessionItem := s.selectedSessionItem()
364+ s.sessionsMode = sessionsModeNormal
365+ if sessionItem == nil {
366+ return nil
367+ }
368+
369+ newTitle := strings.TrimSpace(sessionItem.InputValue())
370+ if newTitle == "" {
371+ return nil
372+ }
373+ session := sessionItem.Session
374+ session.Title = newTitle
375+ s.updateSession(session)
376+ return ActionCmd{s.updateSessionCmd(session)}
377+}
378+
379+func (s *Session) updateSession(session session.Session) {
380+ for existingID, sess := range s.sessions {
381+ if sess.ID == session.ID {
382+ s.sessions[existingID] = session
383+ break
384+ }
385+ }
386+}
387+
388+func (s *Session) updateSessionCmd(session session.Session) tea.Cmd {
389+ return func() tea.Msg {
390+ _, err := s.com.App.Sessions.Save(context.TODO(), session)
391+ if err != nil {
392+ return uiutil.NewErrorMsg(err)
393+ }
394+ return nil
395+ }
396+}
397+
278398func (s *Session) isCurrentSessionBusy() bool {
279399 sessionItem := s.selectedSessionItem()
280400 if sessionItem == nil {
@@ -296,11 +416,17 @@ func (s *Session) ShortHelp() []key.Binding {
296416 s.keyMap.ConfirmDelete,
297417 s.keyMap.CancelDelete,
298418 }
419+ case sessionsModeUpdating:
420+ return []key.Binding{
421+ s.keyMap.ConfirmRename,
422+ s.keyMap.CancelRename,
423+ }
299424 default:
300425 return []key.Binding{
301426 s.keyMap.UpDown,
302− s.keyMap.Select,
427+ s.keyMap.Rename,
303428 s.keyMap.Delete,
429+ s.keyMap.Select,
304430 s.keyMap.Close,
305431 }
306432 }
@@ -310,10 +436,10 @@ func (s *Session) ShortHelp() []key.Binding {
310436func (s *Session) FullHelp() [][]key.Binding {
311437 m := [][]key.Binding{}
312438 slice := []key.Binding{
313− s.keyMap.Select,
314− s.keyMap.Next,
315− s.keyMap.Previous,
439+ s.keyMap.UpDown,
440+ s.keyMap.Rename,
316441 s.keyMap.Delete,
442+ s.keyMap.Select,
317443 s.keyMap.Close,
318444 }
319445
@@ -323,6 +449,11 @@ func (s *Session) FullHelp() [][]key.Binding {
323449 s.keyMap.ConfirmDelete,
324450 s.keyMap.CancelDelete,
325451 }
452+ case sessionsModeUpdating:
453+ slice = []key.Binding{
454+ s.keyMap.ConfirmRename,
455+ s.keyMap.CancelRename,
456+ }
326457 }
327458 for i := 0; i < len(slice); i += 4 {
328459 end := min(i+4, len(slice))
internal/ui/dialog/sessions_item.go+46−6
@@ -5,6 +5,8 @@ import (
55 "strings"
66 "time"
77
8+ "charm.land/bubbles/v2/textinput"
9+ tea "charm.land/bubbletea/v2"
810 "charm.land/lipgloss/v2"
911 "github.com/charmbracelet/crush/internal/session"
1012 "github.com/charmbracelet/crush/internal/ui/list"
@@ -28,11 +30,12 @@ type ListItem interface {
2830// SessionItem wraps a [session.Session] to implement the [ListItem] interface.
2931type SessionItem struct {
3032 session.Session
31− t *styles.Styles
32− sessionsMode sessionsMode
33− m fuzzy.Match
34− cache map[int]string
35− focused bool
33+ t *styles.Styles
34+ sessionsMode sessionsMode
35+ m fuzzy.Match
36+ cache map[int]string
37+ updateTitleInput textinput.Model
38+ focused bool
3639}
3740
3841var _ ListItem = &SessionItem{}
@@ -53,6 +56,23 @@ func (s *SessionItem) SetMatch(m fuzzy.Match) {
5356 s.m = m
5457}
5558
59+// InputValue returns the updated title value
60+func (s *SessionItem) InputValue() string {
61+ return s.updateTitleInput.Value()
62+}
63+
64+// HandleInput forwards input message to the update title input
65+func (s *SessionItem) HandleInput(msg tea.Msg) tea.Cmd {
66+ var cmd tea.Cmd
67+ s.updateTitleInput, cmd = s.updateTitleInput.Update(msg)
68+ return cmd
69+}
70+
71+// Cursor returns the cursor of the update title input
72+func (s *SessionItem) Cursor() *tea.Cursor {
73+ return s.updateTitleInput.Cursor()
74+}
75+
5676// Render returns the string representation of the session item.
5777func (s *SessionItem) Render(width int) string {
5878 info := humanize.Time(time.Unix(s.UpdatedAt, 0))
@@ -67,7 +87,17 @@ func (s *SessionItem) Render(width int) string {
6787 case sessionsModeDeleting:
6888 styles.ItemBlurred = s.t.Dialog.Sessions.DeletingItemBlurred
6989 styles.ItemFocused = s.t.Dialog.Sessions.DeletingItemFocused
90+ case sessionsModeUpdating:
91+ styles.ItemBlurred = s.t.Dialog.Sessions.UpdatingItemBlurred
92+ styles.ItemFocused = s.t.Dialog.Sessions.UpdatingItemFocused
93+ if s.focused {
94+ inputWidth := width - styles.InfoTextFocused.GetHorizontalFrameSize()
95+ s.updateTitleInput.SetWidth(inputWidth)
96+ s.updateTitleInput.Placeholder = ansi.Truncate(s.Title, width, "…")
97+ return styles.ItemFocused.Render(s.updateTitleInput.View())
98+ }
7099 }
100+
71101 return renderItem(styles, s.Title, info, s.focused, width, s.cache, &s.m)
72102}
73103
@@ -157,7 +187,17 @@ func (s *SessionItem) SetFocused(focused bool) {
157187func sessionItems(t *styles.Styles, mode sessionsMode, sessions ...session.Session) []list.FilterableItem {
158188 items := make([]list.FilterableItem, len(sessions))
159189 for i, s := range sessions {
160− items[i] = &SessionItem{Session: s, t: t, sessionsMode: mode}
190+ item := &SessionItem{Session: s, t: t, sessionsMode: mode}
191+ if mode == sessionsModeUpdating {
192+ item.updateTitleInput = textinput.New()
193+ item.updateTitleInput.SetVirtualCursor(false)
194+ item.updateTitleInput.Prompt = ""
195+ inputStyle := t.TextInput
196+ inputStyle.Focused.Placeholder = inputStyle.Focused.Placeholder.Foreground(t.FgHalfMuted)
197+ item.updateTitleInput.SetStyles(inputStyle)
198+ item.updateTitleInput.Focus()
199+ }
200+ items[i] = item
161201 }
162202 return items
163203}
internal/ui/styles/styles.go+18
@@ -369,6 +369,7 @@ type Styles struct {
369369 ImagePreview lipgloss.Style
370370
371371 Sessions struct {
372+ // styles for when we are in delete mode
372373 DeletingView lipgloss.Style
373374 DeletingItemFocused lipgloss.Style
374375 DeletingItemBlurred lipgloss.Style
@@ -376,6 +377,15 @@ type Styles struct {
376377 DeletingMessage lipgloss.Style
377378 DeletingTitleGradientFromColor color.Color
378379 DeletingTitleGradientToColor color.Color
380+
381+ // styles for when we are in update mode
382+ UpdatingView lipgloss.Style
383+ UpdatingItemFocused lipgloss.Style
384+ UpdatingItemBlurred lipgloss.Style
385+ UpdatingTitle lipgloss.Style
386+ UpdatingMessage lipgloss.Style
387+ UpdatingTitleGradientFromColor color.Color
388+ UpdatingTitleGradientToColor color.Color
379389 }
380390 }
381391
@@ -1287,6 +1297,14 @@ func DefaultStyles() Styles {
12871297 s.Dialog.Sessions.DeletingItemBlurred = s.Dialog.NormalItem.Foreground(fgSubtle)
12881298 s.Dialog.Sessions.DeletingItemFocused = s.Dialog.SelectedItem.Background(red)
12891299
1300+ s.Dialog.Sessions.UpdatingTitle = s.Dialog.Title.Foreground(charmtone.Zest)
1301+ s.Dialog.Sessions.UpdatingView = s.Dialog.View.BorderForeground(charmtone.Zest)
1302+ s.Dialog.Sessions.UpdatingMessage = s.Base.Padding(1)
1303+ s.Dialog.Sessions.UpdatingTitleGradientFromColor = charmtone.Zest
1304+ s.Dialog.Sessions.UpdatingTitleGradientToColor = charmtone.Bok
1305+ s.Dialog.Sessions.UpdatingItemBlurred = s.Dialog.NormalItem.Foreground(fgSubtle)
1306+ s.Dialog.Sessions.UpdatingItemFocused = s.Dialog.SelectedItem.UnsetBackground().UnsetForeground()
1307+
12901308 s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
12911309 s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")
12921310 s.Status.InfoIndicator = s.Status.SuccessIndicator