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/ui/util"
 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.Workspace.ListSessions(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{util.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				} else {
194					s.list.SelectPrev()
195				}
196				s.list.ScrollToSelected()
197			case key.Matches(msg, s.keyMap.Next):
198				s.list.Focus()
199				if s.list.IsSelectedLast() {
200					s.list.SelectFirst()
201				} else {
202					s.list.SelectNext()
203				}
204				s.list.ScrollToSelected()
205			case key.Matches(msg, s.keyMap.Select):
206				if item := s.list.SelectedItem(); item != nil {
207					sessionItem := item.(*SessionItem)
208					return ActionSelectSession{sessionItem.Session}
209				}
210			default:
211				var cmd tea.Cmd
212				s.input, cmd = s.input.Update(msg)
213				value := s.input.Value()
214				s.list.SetFilter(value)
215				s.list.ScrollToTop()
216				s.list.SetSelected(0)
217				return ActionCmd{cmd}
218			}
219		}
220	}
221	return nil
222}
223
224// Cursor returns the cursor position relative to the dialog.
225func (s *Session) Cursor() *tea.Cursor {
226	return InputCursor(s.com.Styles, s.input.Cursor())
227}
228
229// Draw implements [Dialog].
230func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
231	t := s.com.Styles
232	width := max(0, min(defaultDialogMaxWidth, area.Dx()-t.Dialog.View.GetHorizontalBorderSize()))
233	height := max(0, min(defaultDialogHeight, area.Dy()-t.Dialog.View.GetVerticalBorderSize()))
234	innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
235	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
236		t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
237		t.Dialog.HelpView.GetVerticalFrameSize() +
238		t.Dialog.View.GetVerticalFrameSize()
239	s.input.SetWidth(max(0, innerWidth-t.Dialog.InputPrompt.GetHorizontalFrameSize()-1)) // (1) cursor padding
240	listHeight := height - heightOffset
241	listTotalHeight := s.list.TotalHeight()
242	listWidth := max(0, innerWidth-3) // Reserve space for scrollbar.
243	s.list.SetSize(listWidth, listHeight)
244	s.help.SetWidth(innerWidth)
245
246	// This makes it so we do not scroll the list if we don't have to
247	start, end := s.list.VisibleItemIndices()
248
249	// if selected index is outside visible range, scroll to it
250	if s.selectedSessionInx < start || s.selectedSessionInx > end {
251		s.list.ScrollToSelected()
252	}
253
254	var cur *tea.Cursor
255	rc := NewRenderContext(t, width)
256	rc.Title = "Sessions"
257	switch s.sessionsMode {
258	case sessionsModeDeleting:
259		rc.TitleStyle = t.Dialog.Sessions.DeletingTitle
260		rc.TitleGradientFromColor = t.Dialog.Sessions.DeletingTitleGradientFromColor
261		rc.TitleGradientToColor = t.Dialog.Sessions.DeletingTitleGradientToColor
262		rc.ViewStyle = t.Dialog.Sessions.DeletingView
263		rc.AddPart(t.Dialog.Sessions.DeletingMessage.Render("Delete this session?"))
264	case sessionsModeUpdating:
265		rc.TitleStyle = t.Dialog.Sessions.RenamingingTitle
266		rc.TitleGradientFromColor = t.Dialog.Sessions.RenamingTitleGradientFromColor
267		rc.TitleGradientToColor = t.Dialog.Sessions.RenamingTitleGradientToColor
268		rc.ViewStyle = t.Dialog.Sessions.RenamingView
269		message := t.Dialog.Sessions.RenamingingMessage.Render("Rename this session?")
270		rc.AddPart(message)
271		item := s.selectedSessionItem()
272		if item == nil {
273			return nil
274		}
275		cur = item.Cursor()
276		if cur == nil {
277			break
278		}
279
280		start, end := s.list.VisibleItemIndices()
281		selectedIndex := s.list.Selected()
282
283		titleStyle := t.Dialog.Sessions.RenamingingTitle
284		dialogStyle := t.Dialog.Sessions.RenamingView
285		inputStyle := t.Dialog.InputPrompt
286
287		// Adjust cursor position to account for dialog layout + message
288		cur.X += inputStyle.GetBorderLeftSize() +
289			inputStyle.GetMarginLeft() +
290			inputStyle.GetPaddingLeft() +
291			dialogStyle.GetBorderLeftSize() +
292			dialogStyle.GetPaddingLeft() +
293			dialogStyle.GetMarginLeft()
294		cur.Y += titleStyle.GetVerticalFrameSize() +
295			inputStyle.GetBorderTopSize() +
296			inputStyle.GetMarginTop() +
297			inputStyle.GetPaddingTop() +
298			inputStyle.GetBorderBottomSize() +
299			inputStyle.GetMarginBottom() +
300			inputStyle.GetPaddingBottom() +
301			dialogStyle.GetPaddingTop() +
302			dialogStyle.GetBorderTopSize() +
303			lipgloss.Height(message) - 1
304
305		// move the cursor by one down until we see the selectedIndex
306		for ; start <= end && start != selectedIndex && selectedIndex > -1; start++ {
307			cur.Y += 1
308		}
309	default:
310		inputView := t.Dialog.InputPrompt.Render(s.input.View())
311		cur = s.Cursor()
312		rc.AddPart(inputView)
313	}
314	listView := t.Dialog.List.Height(s.list.Height()).Render(s.list.Render())
315	scrollbar := common.Scrollbar(t, listHeight, listTotalHeight, listHeight, s.list.Offset())
316	if scrollbar != "" {
317		listView = lipgloss.JoinHorizontal(lipgloss.Top, listView, scrollbar)
318	}
319	rc.AddPart(listView)
320	rc.Help = s.help.View(s)
321
322	view := rc.Render()
323
324	DrawCenterCursor(scr, area, view, cur)
325	return cur
326}
327
328func (s *Session) selectedSessionItem() *SessionItem {
329	if item := s.list.SelectedItem(); item != nil {
330		return item.(*SessionItem)
331	}
332	return nil
333}
334
335func (s *Session) confirmDeleteSession() Action {
336	sessionItem := s.selectedSessionItem()
337	s.sessionsMode = sessionsModeNormal
338	if sessionItem == nil {
339		return nil
340	}
341
342	s.removeSession(sessionItem.ID())
343	return ActionCmd{s.deleteSessionCmd(sessionItem.ID())}
344}
345
346func (s *Session) removeSession(id string) {
347	var newSessions []session.Session
348	for _, sess := range s.sessions {
349		if sess.ID == id {
350			continue
351		}
352		newSessions = append(newSessions, sess)
353	}
354	s.sessions = newSessions
355}
356
357func (s *Session) deleteSessionCmd(id string) tea.Cmd {
358	return func() tea.Msg {
359		err := s.com.Workspace.DeleteSession(context.TODO(), id)
360		if err != nil {
361			return util.NewErrorMsg(err)
362		}
363		return nil
364	}
365}
366
367func (s *Session) confirmRenameSession() Action {
368	sessionItem := s.selectedSessionItem()
369	s.sessionsMode = sessionsModeNormal
370	if sessionItem == nil {
371		return nil
372	}
373
374	newTitle := strings.TrimSpace(sessionItem.InputValue())
375	if newTitle == "" {
376		return nil
377	}
378	session := sessionItem.Session
379	session.Title = newTitle
380	s.updateSession(session)
381	return ActionCmd{s.updateSessionCmd(session)}
382}
383
384func (s *Session) updateSession(session session.Session) {
385	for existingID, sess := range s.sessions {
386		if sess.ID == session.ID {
387			s.sessions[existingID] = session
388			break
389		}
390	}
391}
392
393func (s *Session) updateSessionCmd(session session.Session) tea.Cmd {
394	return func() tea.Msg {
395		_, err := s.com.Workspace.SaveSession(context.TODO(), session)
396		if err != nil {
397			return util.NewErrorMsg(err)
398		}
399		return nil
400	}
401}
402
403func (s *Session) isCurrentSessionBusy() bool {
404	sessionItem := s.selectedSessionItem()
405	if sessionItem == nil {
406		return false
407	}
408
409	if !s.com.Workspace.AgentIsReady() {
410		return false
411	}
412
413	return s.com.Workspace.AgentIsSessionBusy(sessionItem.ID())
414}
415
416// ShortHelp implements [help.KeyMap].
417func (s *Session) ShortHelp() []key.Binding {
418	switch s.sessionsMode {
419	case sessionsModeDeleting:
420		return []key.Binding{
421			s.keyMap.ConfirmDelete,
422			s.keyMap.CancelDelete,
423		}
424	case sessionsModeUpdating:
425		return []key.Binding{
426			s.keyMap.ConfirmRename,
427			s.keyMap.CancelRename,
428		}
429	default:
430		return []key.Binding{
431			s.keyMap.UpDown,
432			s.keyMap.Rename,
433			s.keyMap.Delete,
434			s.keyMap.Select,
435			s.keyMap.Close,
436		}
437	}
438}
439
440// FullHelp implements [help.KeyMap].
441func (s *Session) FullHelp() [][]key.Binding {
442	m := [][]key.Binding{}
443	slice := []key.Binding{
444		s.keyMap.UpDown,
445		s.keyMap.Rename,
446		s.keyMap.Delete,
447		s.keyMap.Select,
448		s.keyMap.Close,
449	}
450
451	switch s.sessionsMode {
452	case sessionsModeDeleting:
453		slice = []key.Binding{
454			s.keyMap.ConfirmDelete,
455			s.keyMap.CancelDelete,
456		}
457	case sessionsModeUpdating:
458		slice = []key.Binding{
459			s.keyMap.ConfirmRename,
460			s.keyMap.CancelRename,
461		}
462	}
463	for i := 0; i < len(slice); i += 4 {
464		end := min(i+4, len(slice))
465		m = append(m, slice[i:end])
466	}
467	return m
468}