sessions.go

  1package dialog
  2
  3import (
  4	"context"
  5	"strings"
  6
  7	"charm.land/bubbles/v2/help"
  8	"charm.land/bubbles/v2/key"
  9	"charm.land/bubbles/v2/textinput"
 10	tea "charm.land/bubbletea/v2"
 11	"charm.land/lipgloss/v2"
 12	"github.com/charmbracelet/crush/internal/session"
 13	"github.com/charmbracelet/crush/internal/ui/common"
 14	"github.com/charmbracelet/crush/internal/ui/list"
 15	"github.com/charmbracelet/crush/internal/uiutil"
 16	uv "github.com/charmbracelet/ultraviolet"
 17)
 18
 19// SessionsID is the identifier for the session selector dialog.
 20const SessionsID = "session"
 21
 22type sessionsMode uint8
 23
 24// Possible modes a session item can be in
 25const (
 26	sessionsModeNormal sessionsMode = iota
 27	sessionsModeDeleting
 28	sessionsModeUpdating
 29)
 30
 31// Session is a session selector dialog.
 32type Session struct {
 33	com                *common.Common
 34	help               help.Model
 35	list               *list.FilterableList
 36	input              textinput.Model
 37	selectedSessionInx int
 38	sessions           []session.Session
 39
 40	sessionsMode sessionsMode
 41
 42	keyMap struct {
 43		Select        key.Binding
 44		Next          key.Binding
 45		Previous      key.Binding
 46		UpDown        key.Binding
 47		Delete        key.Binding
 48		Rename        key.Binding
 49		ConfirmRename key.Binding
 50		CancelRename  key.Binding
 51		ConfirmDelete key.Binding
 52		CancelDelete  key.Binding
 53		Close         key.Binding
 54	}
 55}
 56
 57var _ Dialog = (*Session)(nil)
 58
 59// NewSessions creates a new Session dialog.
 60func NewSessions(com *common.Common, selectedSessionID string) (*Session, error) {
 61	s := new(Session)
 62	s.sessionsMode = sessionsModeNormal
 63	s.com = com
 64	sessions, err := com.App.Sessions.List(context.TODO())
 65	if err != nil {
 66		return nil, err
 67	}
 68
 69	s.sessions = sessions
 70	for i, sess := range sessions {
 71		if sess.ID == selectedSessionID {
 72			s.selectedSessionInx = i
 73			break
 74		}
 75	}
 76
 77	help := help.New()
 78	help.Styles = com.Styles.DialogHelpStyles()
 79
 80	s.help = help
 81	s.list = list.NewFilterableList(sessionItems(com.Styles, sessionsModeNormal, sessions...)...)
 82	s.list.Focus()
 83	s.list.SetSelected(s.selectedSessionInx)
 84
 85	s.input = textinput.New()
 86	s.input.SetVirtualCursor(false)
 87	s.input.Placeholder = "Enter session name"
 88	s.input.SetStyles(com.Styles.TextInput)
 89	s.input.Focus()
 90
 91	s.keyMap.Select = key.NewBinding(
 92		key.WithKeys("enter", "tab", "ctrl+y"),
 93		key.WithHelp("enter", "choose"),
 94	)
 95	s.keyMap.Next = key.NewBinding(
 96		key.WithKeys("down", "ctrl+n"),
 97		key.WithHelp("↓", "next item"),
 98	)
 99	s.keyMap.Previous = key.NewBinding(
100		key.WithKeys("up", "ctrl+p"),
101		key.WithHelp("↑", "previous item"),
102	)
103	s.keyMap.UpDown = key.NewBinding(
104		key.WithKeys("up", "down"),
105		key.WithHelp("↑↓", "choose"),
106	)
107	s.keyMap.Delete = key.NewBinding(
108		key.WithKeys("ctrl+x"),
109		key.WithHelp("ctrl+x", "delete"),
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	)
123	s.keyMap.ConfirmDelete = key.NewBinding(
124		key.WithKeys("y"),
125		key.WithHelp("y", "delete"),
126	)
127	s.keyMap.CancelDelete = key.NewBinding(
128		key.WithKeys("n", "esc"),
129		key.WithHelp("n", "cancel"),
130	)
131	s.keyMap.Close = CloseKey
132
133	return s, nil
134}
135
136// ID implements Dialog.
137func (s *Session) ID() string {
138	return SessionsID
139}
140
141// HandleMsg implements Dialog.
142func (s *Session) HandleMsg(msg tea.Msg) Action {
143	switch msg := msg.(type) {
144	case tea.KeyPressMsg:
145		switch s.sessionsMode {
146		case sessionsModeDeleting:
147			switch {
148			case key.Matches(msg, s.keyMap.ConfirmDelete):
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
154			case key.Matches(msg, s.keyMap.CancelDelete):
155				s.sessionsMode = sessionsModeNormal
156				s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...)
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			}
176		default:
177			switch {
178			case key.Matches(msg, s.keyMap.Close):
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...)...)
183			case key.Matches(msg, s.keyMap.Delete):
184				if s.isCurrentSessionBusy() {
185					return ActionCmd{uiutil.ReportWarn("Agent is busy, please wait...")}
186				}
187				s.sessionsMode = sessionsModeDeleting
188				s.list.SetItems(sessionItems(s.com.Styles, sessionsModeDeleting, s.sessions...)...)
189			case key.Matches(msg, s.keyMap.Previous):
190				s.list.Focus()
191				if s.list.IsSelectedFirst() {
192					s.list.SelectLast()
193					s.list.ScrollToBottom()
194					break
195				}
196				s.list.SelectPrev()
197				s.list.ScrollToSelected()
198			case key.Matches(msg, s.keyMap.Next):
199				s.list.Focus()
200				if s.list.IsSelectedLast() {
201					s.list.SelectFirst()
202					s.list.ScrollToTop()
203					break
204				}
205				s.list.SelectNext()
206				s.list.ScrollToSelected()
207			case key.Matches(msg, s.keyMap.Select):
208				if item := s.list.SelectedItem(); item != nil {
209					sessionItem := item.(*SessionItem)
210					return ActionSelectSession{sessionItem.Session}
211				}
212			default:
213				var cmd tea.Cmd
214				s.input, cmd = s.input.Update(msg)
215				value := s.input.Value()
216				s.list.SetFilter(value)
217				s.list.ScrollToTop()
218				s.list.SetSelected(0)
219				return ActionCmd{cmd}
220			}
221		}
222	}
223	return nil
224}
225
226// Cursor returns the cursor position relative to the dialog.
227func (s *Session) Cursor() *tea.Cursor {
228	return InputCursor(s.com.Styles, s.input.Cursor())
229}
230
231// Draw implements [Dialog].
232func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
233	t := s.com.Styles
234	width := max(0, min(defaultDialogMaxWidth, area.Dx()))
235	height := max(0, min(defaultDialogHeight, area.Dy()))
236	innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2
237	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
238		t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
239		t.Dialog.HelpView.GetVerticalFrameSize() +
240		t.Dialog.View.GetVerticalFrameSize()
241	s.input.SetWidth(max(0, innerWidth-t.Dialog.InputPrompt.GetHorizontalFrameSize()-1)) // (1) cursor padding
242	s.list.SetSize(innerWidth, height-heightOffset)
243	s.help.SetWidth(innerWidth)
244
245	// This makes it so we do not scroll the list if we don't have to
246	start, end := s.list.VisibleItemIndices()
247
248	// if selected index is outside visible range, scroll to it
249	if s.selectedSessionInx < start || s.selectedSessionInx > end {
250		s.list.ScrollToSelected()
251	}
252
253	var cur *tea.Cursor
254	rc := NewRenderContext(t, width)
255	rc.Title = "Sessions"
256	switch s.sessionsMode {
257	case sessionsModeDeleting:
258		rc.TitleStyle = t.Dialog.Sessions.DeletingTitle
259		rc.TitleGradientFromColor = t.Dialog.Sessions.DeletingTitleGradientFromColor
260		rc.TitleGradientToColor = t.Dialog.Sessions.DeletingTitleGradientToColor
261		rc.ViewStyle = t.Dialog.Sessions.DeletingView
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		}
308	default:
309		inputView := t.Dialog.InputPrompt.Render(s.input.View())
310		cur = s.Cursor()
311		rc.AddPart(inputView)
312	}
313	listView := t.Dialog.List.Height(s.list.Height()).Render(s.list.Render())
314	rc.AddPart(listView)
315	rc.Help = s.help.View(s)
316
317	view := rc.Render()
318
319	DrawCenterCursor(scr, area, view, cur)
320	return cur
321}
322
323func (s *Session) selectedSessionItem() *SessionItem {
324	if item := s.list.SelectedItem(); item != nil {
325		return item.(*SessionItem)
326	}
327	return nil
328}
329
330func (s *Session) confirmDeleteSession() Action {
331	sessionItem := s.selectedSessionItem()
332	s.sessionsMode = sessionsModeNormal
333	if sessionItem == nil {
334		return nil
335	}
336
337	s.removeSession(sessionItem.ID())
338	return ActionCmd{s.deleteSessionCmd(sessionItem.ID())}
339}
340
341func (s *Session) removeSession(id string) {
342	var newSessions []session.Session
343	for _, sess := range s.sessions {
344		if sess.ID == id {
345			continue
346		}
347		newSessions = append(newSessions, sess)
348	}
349	s.sessions = newSessions
350}
351
352func (s *Session) deleteSessionCmd(id string) tea.Cmd {
353	return func() tea.Msg {
354		err := s.com.App.Sessions.Delete(context.TODO(), id)
355		if err != nil {
356			return uiutil.NewErrorMsg(err)
357		}
358		return nil
359	}
360}
361
362func (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
379func (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
388func (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
398func (s *Session) isCurrentSessionBusy() bool {
399	sessionItem := s.selectedSessionItem()
400	if sessionItem == nil {
401		return false
402	}
403
404	if s.com.App.AgentCoordinator == nil {
405		return false
406	}
407
408	return s.com.App.AgentCoordinator.IsSessionBusy(sessionItem.ID())
409}
410
411// ShortHelp implements [help.KeyMap].
412func (s *Session) ShortHelp() []key.Binding {
413	switch s.sessionsMode {
414	case sessionsModeDeleting:
415		return []key.Binding{
416			s.keyMap.ConfirmDelete,
417			s.keyMap.CancelDelete,
418		}
419	case sessionsModeUpdating:
420		return []key.Binding{
421			s.keyMap.ConfirmRename,
422			s.keyMap.CancelRename,
423		}
424	default:
425		return []key.Binding{
426			s.keyMap.UpDown,
427			s.keyMap.Rename,
428			s.keyMap.Delete,
429			s.keyMap.Select,
430			s.keyMap.Close,
431		}
432	}
433}
434
435// FullHelp implements [help.KeyMap].
436func (s *Session) FullHelp() [][]key.Binding {
437	m := [][]key.Binding{}
438	slice := []key.Binding{
439		s.keyMap.UpDown,
440		s.keyMap.Rename,
441		s.keyMap.Delete,
442		s.keyMap.Select,
443		s.keyMap.Close,
444	}
445
446	switch s.sessionsMode {
447	case sessionsModeDeleting:
448		slice = []key.Binding{
449			s.keyMap.ConfirmDelete,
450			s.keyMap.CancelDelete,
451		}
452	case sessionsModeUpdating:
453		slice = []key.Binding{
454			s.keyMap.ConfirmRename,
455			s.keyMap.CancelRename,
456		}
457	}
458	for i := 0; i < len(slice); i += 4 {
459		end := min(i+4, len(slice))
460		m = append(m, slice[i:end])
461	}
462	return m
463}