@@ -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))
@@ -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
}