on main help sign in
feat: update session title (#1988)
7154e46Kujtim Hoxha
internal/ui/dialog/sessions.go+139−8
@@ -2,11 +2,13 @@ package dialog
2
2
3import (
3import (
4 "context"
4 "context"
5+ "strings"
5
6
6 "charm.land/bubbles/v2/help"
7 "charm.land/bubbles/v2/help"
7 "charm.land/bubbles/v2/key"
8 "charm.land/bubbles/v2/key"
8 "charm.land/bubbles/v2/textinput"
9 "charm.land/bubbles/v2/textinput"
9 tea "charm.land/bubbletea/v2"
10 tea "charm.land/bubbletea/v2"
11+ "charm.land/lipgloss/v2"
10 "github.com/charmbracelet/crush/internal/session"
12 "github.com/charmbracelet/crush/internal/session"
11 "github.com/charmbracelet/crush/internal/ui/common"
13 "github.com/charmbracelet/crush/internal/ui/common"
12 "github.com/charmbracelet/crush/internal/ui/list"
14 "github.com/charmbracelet/crush/internal/ui/list"
@@ -43,6 +45,9 @@ type Session struct {
43 Previous key.Binding
45 Previous key.Binding
44 UpDown key.Binding
46 UpDown key.Binding
45 Delete key.Binding
47 Delete key.Binding
48+ Rename key.Binding
49+ ConfirmRename key.Binding
50+ CancelRename key.Binding
46 ConfirmDelete key.Binding
51 ConfirmDelete key.Binding
47 CancelDelete key.Binding
52 CancelDelete key.Binding
48 Close key.Binding
53 Close key.Binding
@@ -103,6 +108,18 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error)
103 key.WithKeys("ctrl+x"),
108 key.WithKeys("ctrl+x"),
104 key.WithHelp("ctrl+x", "delete"),
109 key.WithHelp("ctrl+x", "delete"),
105 )
110 )
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+ )
106 s.keyMap.ConfirmDelete = key.NewBinding(
123 s.keyMap.ConfirmDelete = key.NewBinding(
107 key.WithKeys("y"),
124 key.WithKeys("y"),
108 key.WithHelp("y", "delete"),
125 key.WithHelp("y", "delete"),
@@ -129,15 +146,40 @@ func (s *Session) HandleMsg(msg tea.Msg) Action {
129 case sessionsModeDeleting:
146 case sessionsModeDeleting:
130 switch {
147 switch {
131 case key.Matches(msg, s.keyMap.ConfirmDelete):
148 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
133 case key.Matches(msg, s.keyMap.CancelDelete):
154 case key.Matches(msg, s.keyMap.CancelDelete):
134 s.sessionsMode = sessionsModeNormal
155 s.sessionsMode = sessionsModeNormal
135 s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...)
156 s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...)
136 }
157 }
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+ }
137 default:
176 default:
138 switch {
177 switch {
139 case key.Matches(msg, s.keyMap.Close):
178 case key.Matches(msg, s.keyMap.Close):
140 return ActionClose{}
179 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...)...)
141 case key.Matches(msg, s.keyMap.Delete):
183 case key.Matches(msg, s.keyMap.Delete):
142 if s.isCurrentSessionBusy() {
184 if s.isCurrentSessionBusy() {
143 return ActionCmd{uiutil.ReportWarn("Agent is busy, please wait...")}
185 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 {
218 rc.TitleGradientToColor = t.Dialog.Sessions.DeletingTitleGradientToColor
260 rc.TitleGradientToColor = t.Dialog.Sessions.DeletingTitleGradientToColor
219 rc.ViewStyle = t.Dialog.Sessions.DeletingView
261 rc.ViewStyle = t.Dialog.Sessions.DeletingView
220 rc.AddPart(t.Dialog.Sessions.DeletingMessage.Render("Delete this session?"))
262 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+ }
221 default:
308 default:
222 inputView := t.Dialog.InputPrompt.Render(s.input.View())
309 inputView := t.Dialog.InputPrompt.Render(s.input.View())
223 cur = s.Cursor()
310 cur = s.Cursor()
@@ -260,9 +347,6 @@ func (s *Session) removeSession(id string) {
260 newSessions = append(newSessions, sess)
347 newSessions = append(newSessions, sess)
261 }
348 }
262 s.sessions = newSessions
349 s.sessions = newSessions
263− s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...)
264− s.list.SelectFirst()
265− s.list.ScrollToSelected()
266}
350}
267
351
268func (s *Session) deleteSessionCmd(id string) tea.Cmd {
352func (s *Session) deleteSessionCmd(id string) tea.Cmd {
@@ -275,6 +359,42 @@ func (s *Session) deleteSessionCmd(id string) tea.Cmd {
275 }
359 }
276}
360}
277
361
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+
278func (s *Session) isCurrentSessionBusy() bool {
398func (s *Session) isCurrentSessionBusy() bool {
279 sessionItem := s.selectedSessionItem()
399 sessionItem := s.selectedSessionItem()
280 if sessionItem == nil {
400 if sessionItem == nil {
@@ -296,11 +416,17 @@ func (s *Session) ShortHelp() []key.Binding {
296 s.keyMap.ConfirmDelete,
416 s.keyMap.ConfirmDelete,
297 s.keyMap.CancelDelete,
417 s.keyMap.CancelDelete,
298 }
418 }
419+ case sessionsModeUpdating:
420+ return []key.Binding{
421+ s.keyMap.ConfirmRename,
422+ s.keyMap.CancelRename,
423+ }
299 default:
424 default:
300 return []key.Binding{
425 return []key.Binding{
301 s.keyMap.UpDown,
426 s.keyMap.UpDown,
302− s.keyMap.Select,
427+ s.keyMap.Rename,
303 s.keyMap.Delete,
428 s.keyMap.Delete,
429+ s.keyMap.Select,
304 s.keyMap.Close,
430 s.keyMap.Close,
305 }
431 }
306 }
432 }
@@ -310,10 +436,10 @@ func (s *Session) ShortHelp() []key.Binding {
310func (s *Session) FullHelp() [][]key.Binding {
436func (s *Session) FullHelp() [][]key.Binding {
311 m := [][]key.Binding{}
437 m := [][]key.Binding{}
312 slice := []key.Binding{
438 slice := []key.Binding{
313− s.keyMap.Select,
439+ s.keyMap.UpDown,
314− s.keyMap.Next,
440+ s.keyMap.Rename,
315− s.keyMap.Previous,
316 s.keyMap.Delete,
441 s.keyMap.Delete,
442+ s.keyMap.Select,
317 s.keyMap.Close,
443 s.keyMap.Close,
318 }
444 }
319
445
@@ -323,6 +449,11 @@ func (s *Session) FullHelp() [][]key.Binding {
323 s.keyMap.ConfirmDelete,
449 s.keyMap.ConfirmDelete,
324 s.keyMap.CancelDelete,
450 s.keyMap.CancelDelete,
325 }
451 }
452+ case sessionsModeUpdating:
453+ slice = []key.Binding{
454+ s.keyMap.ConfirmRename,
455+ s.keyMap.CancelRename,
456+ }
326 }
457 }
327 for i := 0; i < len(slice); i += 4 {
458 for i := 0; i < len(slice); i += 4 {
328 end := min(i+4, len(slice))
459 end := min(i+4, len(slice))
internal/ui/dialog/sessions_item.go+46−6
@@ -5,6 +5,8 @@ import (
5 "strings"
5 "strings"
6 "time"
6 "time"
7
7
8+ "charm.land/bubbles/v2/textinput"
9+ tea "charm.land/bubbletea/v2"
8 "charm.land/lipgloss/v2"
10 "charm.land/lipgloss/v2"
9 "github.com/charmbracelet/crush/internal/session"
11 "github.com/charmbracelet/crush/internal/session"
10 "github.com/charmbracelet/crush/internal/ui/list"
12 "github.com/charmbracelet/crush/internal/ui/list"
@@ -28,11 +30,12 @@ type ListItem interface {
28// SessionItem wraps a [session.Session] to implement the [ListItem] interface.
30// SessionItem wraps a [session.Session] to implement the [ListItem] interface.
29type SessionItem struct {
31type SessionItem struct {
30 session.Session
32 session.Session
31− t *styles.Styles
33+ t *styles.Styles
32− sessionsMode sessionsMode
34+ sessionsMode sessionsMode
33− m fuzzy.Match
35+ m fuzzy.Match
34− cache map[int]string
36+ cache map[int]string
37+ updateTitleInput textinput.Model
35− focused bool
38+ focused bool
36}
39}
37
40
38var _ ListItem = &SessionItem{}
41var _ ListItem = &SessionItem{}
@@ -53,6 +56,23 @@ func (s *SessionItem) SetMatch(m fuzzy.Match) {
53 s.m = m
56 s.m = m
54}
57}
55
58
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+
56// Render returns the string representation of the session item.
76// Render returns the string representation of the session item.
57func (s *SessionItem) Render(width int) string {
77func (s *SessionItem) Render(width int) string {
58 info := humanize.Time(time.Unix(s.UpdatedAt, 0))
78 info := humanize.Time(time.Unix(s.UpdatedAt, 0))
@@ -67,7 +87,17 @@ func (s *SessionItem) Render(width int) string {
67 case sessionsModeDeleting:
87 case sessionsModeDeleting:
68 styles.ItemBlurred = s.t.Dialog.Sessions.DeletingItemBlurred
88 styles.ItemBlurred = s.t.Dialog.Sessions.DeletingItemBlurred
69 styles.ItemFocused = s.t.Dialog.Sessions.DeletingItemFocused
89 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+ }
70 }
99 }
100+
71 return renderItem(styles, s.Title, info, s.focused, width, s.cache, &s.m)
101 return renderItem(styles, s.Title, info, s.focused, width, s.cache, &s.m)
72}
102}
73
103
@@ -157,7 +187,17 @@ func (s *SessionItem) SetFocused(focused bool) {
157func sessionItems(t *styles.Styles, mode sessionsMode, sessions ...session.Session) []list.FilterableItem {
187func sessionItems(t *styles.Styles, mode sessionsMode, sessions ...session.Session) []list.FilterableItem {
158 items := make([]list.FilterableItem, len(sessions))
188 items := make([]list.FilterableItem, len(sessions))
159 for i, s := range sessions {
189 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
161 }
201 }
162 return items
202 return items
163}
203}
internal/ui/styles/styles.go+18
@@ -369,6 +369,7 @@ type Styles struct {
369 ImagePreview lipgloss.Style
369 ImagePreview lipgloss.Style
370
370
371 Sessions struct {
371 Sessions struct {
372+ // styles for when we are in delete mode
372 DeletingView lipgloss.Style
373 DeletingView lipgloss.Style
373 DeletingItemFocused lipgloss.Style
374 DeletingItemFocused lipgloss.Style
374 DeletingItemBlurred lipgloss.Style
375 DeletingItemBlurred lipgloss.Style
@@ -376,6 +377,15 @@ type Styles struct {
376 DeletingMessage lipgloss.Style
377 DeletingMessage lipgloss.Style
377 DeletingTitleGradientFromColor color.Color
378 DeletingTitleGradientFromColor color.Color
378 DeletingTitleGradientToColor color.Color
379 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
379 }
389 }
380 }
390 }
381
391
@@ -1287,6 +1297,14 @@ func DefaultStyles() Styles {
1287 s.Dialog.Sessions.DeletingItemBlurred = s.Dialog.NormalItem.Foreground(fgSubtle)
1297 s.Dialog.Sessions.DeletingItemBlurred = s.Dialog.NormalItem.Foreground(fgSubtle)
1288 s.Dialog.Sessions.DeletingItemFocused = s.Dialog.SelectedItem.Background(red)
1298 s.Dialog.Sessions.DeletingItemFocused = s.Dialog.SelectedItem.Background(red)
1289
1299
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+
1290 s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
1308 s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
1291 s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")
1309 s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")
1292 s.Status.InfoIndicator = s.Status.SuccessIndicator
1310 s.Status.InfoIndicator = s.Status.SuccessIndicator