feat: update session title (#1988)

Kujtim Hoxha created

* feat: update session title

* chore: implement review requests

Change summary

internal/ui/dialog/sessions.go      | 147 +++++++++++++++++++++++++++++-
internal/ui/dialog/sessions_item.go |  52 +++++++++-
internal/ui/styles/styles.go        |  18 +++
3 files changed, 203 insertions(+), 14 deletions(-)

Detailed changes

internal/ui/dialog/sessions.go 🔗

@@ -2,11 +2,13 @@ package dialog
 
 import (
 	"context"
+	"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"
@@ -43,6 +45,9 @@ type Session struct {
 		Previous      key.Binding
 		UpDown        key.Binding
 		Delete        key.Binding
+		Rename        key.Binding
+		ConfirmRename key.Binding
+		CancelRename  key.Binding
 		ConfirmDelete key.Binding
 		CancelDelete  key.Binding
 		Close         key.Binding
@@ -103,6 +108,18 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error)
 		key.WithKeys("ctrl+x"),
 		key.WithHelp("ctrl+x", "delete"),
 	)
+	s.keyMap.Rename = key.NewBinding(
+		key.WithKeys("ctrl+r"),
+		key.WithHelp("ctrl+r", "rename"),
+	)
+	s.keyMap.ConfirmRename = key.NewBinding(
+		key.WithKeys("enter"),
+		key.WithHelp("enter", "confirm"),
+	)
+	s.keyMap.CancelRename = key.NewBinding(
+		key.WithKeys("esc"),
+		key.WithHelp("esc", "cancel"),
+	)
 	s.keyMap.ConfirmDelete = key.NewBinding(
 		key.WithKeys("y"),
 		key.WithHelp("y", "delete"),
@@ -129,15 +146,40 @@ func (s *Session) HandleMsg(msg tea.Msg) Action {
 		case sessionsModeDeleting:
 			switch {
 			case key.Matches(msg, s.keyMap.ConfirmDelete):
-				return s.confirmDeleteSession()
+				action := s.confirmDeleteSession()
+				s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...)
+				s.list.SelectFirst()
+				s.list.ScrollToSelected()
+				return action
 			case key.Matches(msg, s.keyMap.CancelDelete):
 				s.sessionsMode = sessionsModeNormal
 				s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...)
 			}
+		case sessionsModeUpdating:
+			switch {
+			case key.Matches(msg, s.keyMap.ConfirmRename):
+				action := s.confirmRenameSession()
+				s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...)
+				return action
+			case key.Matches(msg, s.keyMap.CancelRename):
+				s.sessionsMode = sessionsModeNormal
+				s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...)
+			default:
+				item := s.list.SelectedItem()
+				if item == nil {
+					return nil
+				}
+				if sessionItem, ok := item.(*SessionItem); ok {
+					return sessionItem.HandleInput(msg)
+				}
+			}
 		default:
 			switch {
 			case key.Matches(msg, s.keyMap.Close):
 				return ActionClose{}
+			case key.Matches(msg, s.keyMap.Rename):
+				s.sessionsMode = sessionsModeUpdating
+				s.list.SetItems(sessionItems(s.com.Styles, sessionsModeUpdating, s.sessions...)...)
 			case key.Matches(msg, s.keyMap.Delete):
 				if s.isCurrentSessionBusy() {
 					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 {
 		rc.TitleGradientToColor = t.Dialog.Sessions.DeletingTitleGradientToColor
 		rc.ViewStyle = t.Dialog.Sessions.DeletingView
 		rc.AddPart(t.Dialog.Sessions.DeletingMessage.Render("Delete this session?"))
+	case sessionsModeUpdating:
+		rc.TitleStyle = t.Dialog.Sessions.UpdatingTitle
+		rc.TitleGradientFromColor = t.Dialog.Sessions.UpdatingTitleGradientFromColor
+		rc.TitleGradientToColor = t.Dialog.Sessions.UpdatingTitleGradientToColor
+		rc.ViewStyle = t.Dialog.Sessions.UpdatingView
+		message := t.Dialog.Sessions.UpdatingMessage.Render("Rename this session?")
+		rc.AddPart(message)
+		item := s.selectedSessionItem()
+		if item == nil {
+			return nil
+		}
+		cur = item.Cursor()
+		if cur == nil {
+			break
+		}
+
+		start, end := s.list.VisibleItemIndices()
+		selectedIndex := s.list.Selected()
+
+		titleStyle := t.Dialog.Sessions.UpdatingTitle
+		dialogStyle := t.Dialog.Sessions.UpdatingView
+		inputStyle := t.Dialog.InputPrompt
+
+		// Adjust cursor position to account for dialog layout + message
+		cur.X += inputStyle.GetBorderLeftSize() +
+			inputStyle.GetMarginLeft() +
+			inputStyle.GetPaddingLeft() +
+			dialogStyle.GetBorderLeftSize() +
+			dialogStyle.GetPaddingLeft() +
+			dialogStyle.GetMarginLeft()
+		cur.Y += titleStyle.GetVerticalFrameSize() +
+			inputStyle.GetBorderTopSize() +
+			inputStyle.GetMarginTop() +
+			inputStyle.GetPaddingTop() +
+			inputStyle.GetBorderBottomSize() +
+			inputStyle.GetMarginBottom() +
+			inputStyle.GetPaddingBottom() +
+			dialogStyle.GetPaddingTop() +
+			dialogStyle.GetBorderTopSize() +
+			lipgloss.Height(message) - 1
+
+		// move the cursor by one down until we see the selectedIndex
+		for ; start <= end && start != selectedIndex && selectedIndex > -1; start++ {
+			cur.Y += 1
+		}
 	default:
 		inputView := t.Dialog.InputPrompt.Render(s.input.View())
 		cur = s.Cursor()
@@ -260,9 +347,6 @@ func (s *Session) removeSession(id string) {
 		newSessions = append(newSessions, sess)
 	}
 	s.sessions = newSessions
-	s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...)
-	s.list.SelectFirst()
-	s.list.ScrollToSelected()
 }
 
 func (s *Session) deleteSessionCmd(id string) tea.Cmd {
@@ -275,6 +359,42 @@ func (s *Session) deleteSessionCmd(id string) tea.Cmd {
 	}
 }
 
+func (s *Session) confirmRenameSession() Action {
+	sessionItem := s.selectedSessionItem()
+	s.sessionsMode = sessionsModeNormal
+	if sessionItem == nil {
+		return nil
+	}
+
+	newTitle := strings.TrimSpace(sessionItem.InputValue())
+	if newTitle == "" {
+		return nil
+	}
+	session := sessionItem.Session
+	session.Title = newTitle
+	s.updateSession(session)
+	return ActionCmd{s.updateSessionCmd(session)}
+}
+
+func (s *Session) updateSession(session session.Session) {
+	for existingID, sess := range s.sessions {
+		if sess.ID == session.ID {
+			s.sessions[existingID] = session
+			break
+		}
+	}
+}
+
+func (s *Session) updateSessionCmd(session session.Session) tea.Cmd {
+	return func() tea.Msg {
+		_, err := s.com.App.Sessions.Save(context.TODO(), session)
+		if err != nil {
+			return uiutil.NewErrorMsg(err)
+		}
+		return nil
+	}
+}
+
 func (s *Session) isCurrentSessionBusy() bool {
 	sessionItem := s.selectedSessionItem()
 	if sessionItem == nil {
@@ -296,11 +416,17 @@ func (s *Session) ShortHelp() []key.Binding {
 			s.keyMap.ConfirmDelete,
 			s.keyMap.CancelDelete,
 		}
+	case sessionsModeUpdating:
+		return []key.Binding{
+			s.keyMap.ConfirmRename,
+			s.keyMap.CancelRename,
+		}
 	default:
 		return []key.Binding{
 			s.keyMap.UpDown,
-			s.keyMap.Select,
+			s.keyMap.Rename,
 			s.keyMap.Delete,
+			s.keyMap.Select,
 			s.keyMap.Close,
 		}
 	}
@@ -310,10 +436,10 @@ func (s *Session) ShortHelp() []key.Binding {
 func (s *Session) FullHelp() [][]key.Binding {
 	m := [][]key.Binding{}
 	slice := []key.Binding{
-		s.keyMap.Select,
-		s.keyMap.Next,
-		s.keyMap.Previous,
+		s.keyMap.UpDown,
+		s.keyMap.Rename,
 		s.keyMap.Delete,
+		s.keyMap.Select,
 		s.keyMap.Close,
 	}
 
@@ -323,6 +449,11 @@ func (s *Session) FullHelp() [][]key.Binding {
 			s.keyMap.ConfirmDelete,
 			s.keyMap.CancelDelete,
 		}
+	case sessionsModeUpdating:
+		slice = []key.Binding{
+			s.keyMap.ConfirmRename,
+			s.keyMap.CancelRename,
+		}
 	}
 	for i := 0; i < len(slice); i += 4 {
 		end := min(i+4, len(slice))

internal/ui/dialog/sessions_item.go 🔗

@@ -5,6 +5,8 @@ import (
 	"strings"
 	"time"
 
+	"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/list"
@@ -28,11 +30,12 @@ type ListItem interface {
 // SessionItem wraps a [session.Session] to implement the [ListItem] interface.
 type SessionItem struct {
 	session.Session
-	t            *styles.Styles
-	sessionsMode sessionsMode
-	m            fuzzy.Match
-	cache        map[int]string
-	focused      bool
+	t                *styles.Styles
+	sessionsMode     sessionsMode
+	m                fuzzy.Match
+	cache            map[int]string
+	updateTitleInput textinput.Model
+	focused          bool
 }
 
 var _ ListItem = &SessionItem{}
@@ -53,6 +56,23 @@ func (s *SessionItem) SetMatch(m fuzzy.Match) {
 	s.m = m
 }
 
+// InputValue returns the updated title value
+func (s *SessionItem) InputValue() string {
+	return s.updateTitleInput.Value()
+}
+
+// HandleInput forwards input message to the update title input
+func (s *SessionItem) HandleInput(msg tea.Msg) tea.Cmd {
+	var cmd tea.Cmd
+	s.updateTitleInput, cmd = s.updateTitleInput.Update(msg)
+	return cmd
+}
+
+// Cursor returns the cursor of the update title input
+func (s *SessionItem) Cursor() *tea.Cursor {
+	return s.updateTitleInput.Cursor()
+}
+
 // Render returns the string representation of the session item.
 func (s *SessionItem) Render(width int) string {
 	info := humanize.Time(time.Unix(s.UpdatedAt, 0))
@@ -67,7 +87,17 @@ func (s *SessionItem) Render(width int) string {
 	case sessionsModeDeleting:
 		styles.ItemBlurred = s.t.Dialog.Sessions.DeletingItemBlurred
 		styles.ItemFocused = s.t.Dialog.Sessions.DeletingItemFocused
+	case sessionsModeUpdating:
+		styles.ItemBlurred = s.t.Dialog.Sessions.UpdatingItemBlurred
+		styles.ItemFocused = s.t.Dialog.Sessions.UpdatingItemFocused
+		if s.focused {
+			inputWidth := width - styles.InfoTextFocused.GetHorizontalFrameSize()
+			s.updateTitleInput.SetWidth(inputWidth)
+			s.updateTitleInput.Placeholder = ansi.Truncate(s.Title, width, "…")
+			return styles.ItemFocused.Render(s.updateTitleInput.View())
+		}
 	}
+
 	return renderItem(styles, s.Title, info, s.focused, width, s.cache, &s.m)
 }
 
@@ -157,7 +187,17 @@ func (s *SessionItem) SetFocused(focused bool) {
 func sessionItems(t *styles.Styles, mode sessionsMode, sessions ...session.Session) []list.FilterableItem {
 	items := make([]list.FilterableItem, len(sessions))
 	for i, s := range sessions {
-		items[i] = &SessionItem{Session: s, t: t, sessionsMode: mode}
+		item := &SessionItem{Session: s, t: t, sessionsMode: mode}
+		if mode == sessionsModeUpdating {
+			item.updateTitleInput = textinput.New()
+			item.updateTitleInput.SetVirtualCursor(false)
+			item.updateTitleInput.Prompt = ""
+			inputStyle := t.TextInput
+			inputStyle.Focused.Placeholder = inputStyle.Focused.Placeholder.Foreground(t.FgHalfMuted)
+			item.updateTitleInput.SetStyles(inputStyle)
+			item.updateTitleInput.Focus()
+		}
+		items[i] = item
 	}
 	return items
 }

internal/ui/styles/styles.go 🔗

@@ -369,6 +369,7 @@ type Styles struct {
 		ImagePreview lipgloss.Style
 
 		Sessions struct {
+			// styles for when we are in delete mode
 			DeletingView                   lipgloss.Style
 			DeletingItemFocused            lipgloss.Style
 			DeletingItemBlurred            lipgloss.Style
@@ -376,6 +377,15 @@ type Styles struct {
 			DeletingMessage                lipgloss.Style
 			DeletingTitleGradientFromColor color.Color
 			DeletingTitleGradientToColor   color.Color
+
+			// styles for when we are in update mode
+			UpdatingView                   lipgloss.Style
+			UpdatingItemFocused            lipgloss.Style
+			UpdatingItemBlurred            lipgloss.Style
+			UpdatingTitle                  lipgloss.Style
+			UpdatingMessage                lipgloss.Style
+			UpdatingTitleGradientFromColor color.Color
+			UpdatingTitleGradientToColor   color.Color
 		}
 	}
 
@@ -1287,6 +1297,14 @@ func DefaultStyles() Styles {
 	s.Dialog.Sessions.DeletingItemBlurred = s.Dialog.NormalItem.Foreground(fgSubtle)
 	s.Dialog.Sessions.DeletingItemFocused = s.Dialog.SelectedItem.Background(red)
 
+	s.Dialog.Sessions.UpdatingTitle = s.Dialog.Title.Foreground(charmtone.Zest)
+	s.Dialog.Sessions.UpdatingView = s.Dialog.View.BorderForeground(charmtone.Zest)
+	s.Dialog.Sessions.UpdatingMessage = s.Base.Padding(1)
+	s.Dialog.Sessions.UpdatingTitleGradientFromColor = charmtone.Zest
+	s.Dialog.Sessions.UpdatingTitleGradientToColor = charmtone.Bok
+	s.Dialog.Sessions.UpdatingItemBlurred = s.Dialog.NormalItem.Foreground(fgSubtle)
+	s.Dialog.Sessions.UpdatingItemFocused = s.Dialog.SelectedItem.UnsetBackground().UnsetForeground()
+
 	s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
 	s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")
 	s.Status.InfoIndicator = s.Status.SuccessIndicator