diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index 2dadc209bced543077a143d03bbc16f6bdf1524d..4f607ab0e23d43b58eac7784abc3fed658d4bcba 100644 --- a/internal/ui/dialog/sessions.go +++ b/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)) diff --git a/internal/ui/dialog/sessions_item.go b/internal/ui/dialog/sessions_item.go index 47ffd4878727c10a4be89ed373402dd214573a14..87a2627daa3b63eca309feeb914ec80c33e2ef1f 100644 --- a/internal/ui/dialog/sessions_item.go +++ b/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 } diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index cce2471267bb179bf26cee2b29c870c0998584fb..455658e7f4900196f7c03dcc1564ea734f780a64 100644 --- a/internal/ui/styles/styles.go +++ b/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