sessions.go

  1package dialog
  2
  3import (
  4	"context"
  5
  6	"charm.land/bubbles/v2/help"
  7	"charm.land/bubbles/v2/key"
  8	"charm.land/bubbles/v2/textinput"
  9	tea "charm.land/bubbletea/v2"
 10	"github.com/charmbracelet/crush/internal/session"
 11	"github.com/charmbracelet/crush/internal/ui/common"
 12	"github.com/charmbracelet/crush/internal/ui/list"
 13	"github.com/charmbracelet/crush/internal/uiutil"
 14	uv "github.com/charmbracelet/ultraviolet"
 15)
 16
 17// SessionsID is the identifier for the session selector dialog.
 18const SessionsID = "session"
 19
 20type sessionsMode uint8
 21
 22// Possible modes a session item can be in
 23const (
 24	sessionsModeNormal sessionsMode = iota
 25	sessionsModeDeleting
 26	sessionsModeUpdating
 27)
 28
 29// Session is a session selector dialog.
 30type Session struct {
 31	com                *common.Common
 32	help               help.Model
 33	list               *list.FilterableList
 34	input              textinput.Model
 35	selectedSessionInx int
 36	sessions           []session.Session
 37
 38	sessionsMode sessionsMode
 39
 40	keyMap struct {
 41		Select        key.Binding
 42		Next          key.Binding
 43		Previous      key.Binding
 44		UpDown        key.Binding
 45		Delete        key.Binding
 46		ConfirmDelete key.Binding
 47		CancelDelete  key.Binding
 48		Close         key.Binding
 49	}
 50}
 51
 52var _ Dialog = (*Session)(nil)
 53
 54// NewSessions creates a new Session dialog.
 55func NewSessions(com *common.Common, selectedSessionID string) (*Session, error) {
 56	s := new(Session)
 57	s.sessionsMode = sessionsModeNormal
 58	s.com = com
 59	sessions, err := com.App.Sessions.List(context.TODO())
 60	if err != nil {
 61		return nil, err
 62	}
 63
 64	s.sessions = sessions
 65	for i, sess := range sessions {
 66		if sess.ID == selectedSessionID {
 67			s.selectedSessionInx = i
 68			break
 69		}
 70	}
 71
 72	help := help.New()
 73	help.Styles = com.Styles.DialogHelpStyles()
 74
 75	s.help = help
 76	s.list = list.NewFilterableList(sessionItems(com.Styles, sessionsModeNormal, sessions...)...)
 77	s.list.Focus()
 78	s.list.SetSelected(s.selectedSessionInx)
 79
 80	s.input = textinput.New()
 81	s.input.SetVirtualCursor(false)
 82	s.input.Placeholder = "Enter session name"
 83	s.input.SetStyles(com.Styles.TextInput)
 84	s.input.Focus()
 85
 86	s.keyMap.Select = key.NewBinding(
 87		key.WithKeys("enter", "tab", "ctrl+y"),
 88		key.WithHelp("enter", "choose"),
 89	)
 90	s.keyMap.Next = key.NewBinding(
 91		key.WithKeys("down", "ctrl+n"),
 92		key.WithHelp("↓", "next item"),
 93	)
 94	s.keyMap.Previous = key.NewBinding(
 95		key.WithKeys("up", "ctrl+p"),
 96		key.WithHelp("↑", "previous item"),
 97	)
 98	s.keyMap.UpDown = key.NewBinding(
 99		key.WithKeys("up", "down"),
100		key.WithHelp("↑↓", "choose"),
101	)
102	s.keyMap.Delete = key.NewBinding(
103		key.WithKeys("ctrl+x"),
104		key.WithHelp("ctrl+x", "delete"),
105	)
106	s.keyMap.ConfirmDelete = key.NewBinding(
107		key.WithKeys("y"),
108		key.WithHelp("y", "delete"),
109	)
110	s.keyMap.CancelDelete = key.NewBinding(
111		key.WithKeys("n", "esc"),
112		key.WithHelp("n", "cancel"),
113	)
114	s.keyMap.Close = CloseKey
115
116	return s, nil
117}
118
119// ID implements Dialog.
120func (s *Session) ID() string {
121	return SessionsID
122}
123
124// HandleMsg implements Dialog.
125func (s *Session) HandleMsg(msg tea.Msg) Action {
126	switch msg := msg.(type) {
127	case tea.KeyPressMsg:
128		switch s.sessionsMode {
129		case sessionsModeDeleting:
130			switch {
131			case key.Matches(msg, s.keyMap.ConfirmDelete):
132				return s.confirmDeleteSession()
133			case key.Matches(msg, s.keyMap.CancelDelete):
134				s.sessionsMode = sessionsModeNormal
135				s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...)
136			}
137		default:
138			switch {
139			case key.Matches(msg, s.keyMap.Close):
140				return ActionClose{}
141			case key.Matches(msg, s.keyMap.Delete):
142				if s.isCurrentSessionBusy() {
143					return ActionCmd{uiutil.ReportWarn("Agent is busy, please wait...")}
144				}
145				s.sessionsMode = sessionsModeDeleting
146				s.list.SetItems(sessionItems(s.com.Styles, sessionsModeDeleting, s.sessions...)...)
147			case key.Matches(msg, s.keyMap.Previous):
148				s.list.Focus()
149				if s.list.IsSelectedFirst() {
150					s.list.SelectLast()
151					s.list.ScrollToBottom()
152					break
153				}
154				s.list.SelectPrev()
155				s.list.ScrollToSelected()
156			case key.Matches(msg, s.keyMap.Next):
157				s.list.Focus()
158				if s.list.IsSelectedLast() {
159					s.list.SelectFirst()
160					s.list.ScrollToTop()
161					break
162				}
163				s.list.SelectNext()
164				s.list.ScrollToSelected()
165			case key.Matches(msg, s.keyMap.Select):
166				if item := s.list.SelectedItem(); item != nil {
167					sessionItem := item.(*SessionItem)
168					return ActionSelectSession{sessionItem.Session}
169				}
170			default:
171				var cmd tea.Cmd
172				s.input, cmd = s.input.Update(msg)
173				value := s.input.Value()
174				s.list.SetFilter(value)
175				s.list.ScrollToTop()
176				s.list.SetSelected(0)
177				return ActionCmd{cmd}
178			}
179		}
180	}
181	return nil
182}
183
184// Cursor returns the cursor position relative to the dialog.
185func (s *Session) Cursor() *tea.Cursor {
186	return InputCursor(s.com.Styles, s.input.Cursor())
187}
188
189// Draw implements [Dialog].
190func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
191	t := s.com.Styles
192	width := max(0, min(defaultDialogMaxWidth, area.Dx()))
193	height := max(0, min(defaultDialogHeight, area.Dy()))
194	innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2
195	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
196		t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
197		t.Dialog.HelpView.GetVerticalFrameSize() +
198		t.Dialog.View.GetVerticalFrameSize()
199	s.input.SetWidth(max(0, innerWidth-t.Dialog.InputPrompt.GetHorizontalFrameSize()-1)) // (1) cursor padding
200	s.list.SetSize(innerWidth, height-heightOffset)
201	s.help.SetWidth(innerWidth)
202
203	// This makes it so we do not scroll the list if we don't have to
204	start, end := s.list.VisibleItemIndices()
205
206	// if selected index is outside visible range, scroll to it
207	if s.selectedSessionInx < start || s.selectedSessionInx > end {
208		s.list.ScrollToSelected()
209	}
210
211	var cur *tea.Cursor
212	rc := NewRenderContext(t, width)
213	rc.Title = "Sessions"
214	switch s.sessionsMode {
215	case sessionsModeDeleting:
216		rc.TitleStyle = t.Dialog.Sessions.DeletingTitle
217		rc.TitleGradientFromColor = t.Dialog.Sessions.DeletingTitleGradientFromColor
218		rc.TitleGradientToColor = t.Dialog.Sessions.DeletingTitleGradientToColor
219		rc.ViewStyle = t.Dialog.Sessions.DeletingView
220		rc.AddPart(t.Dialog.Sessions.DeletingMessage.Render("Delete this session?"))
221	default:
222		inputView := t.Dialog.InputPrompt.Render(s.input.View())
223		cur = s.Cursor()
224		rc.AddPart(inputView)
225	}
226	listView := t.Dialog.List.Height(s.list.Height()).Render(s.list.Render())
227	rc.AddPart(listView)
228	rc.Help = s.help.View(s)
229
230	view := rc.Render()
231
232	DrawCenterCursor(scr, area, view, cur)
233	return cur
234}
235
236func (s *Session) selectedSessionItem() *SessionItem {
237	if item := s.list.SelectedItem(); item != nil {
238		return item.(*SessionItem)
239	}
240	return nil
241}
242
243func (s *Session) confirmDeleteSession() Action {
244	sessionItem := s.selectedSessionItem()
245	s.sessionsMode = sessionsModeNormal
246	if sessionItem == nil {
247		return nil
248	}
249
250	s.removeSession(sessionItem.ID())
251	return ActionCmd{s.deleteSessionCmd(sessionItem.ID())}
252}
253
254func (s *Session) removeSession(id string) {
255	var newSessions []session.Session
256	for _, sess := range s.sessions {
257		if sess.ID == id {
258			continue
259		}
260		newSessions = append(newSessions, sess)
261	}
262	s.sessions = newSessions
263	s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...)
264	s.list.SelectFirst()
265	s.list.ScrollToSelected()
266}
267
268func (s *Session) deleteSessionCmd(id string) tea.Cmd {
269	return func() tea.Msg {
270		err := s.com.App.Sessions.Delete(context.TODO(), id)
271		if err != nil {
272			return uiutil.NewErrorMsg(err)
273		}
274		return nil
275	}
276}
277
278func (s *Session) isCurrentSessionBusy() bool {
279	sessionItem := s.selectedSessionItem()
280	if sessionItem == nil {
281		return false
282	}
283
284	if s.com.App.AgentCoordinator == nil {
285		return false
286	}
287
288	return s.com.App.AgentCoordinator.IsSessionBusy(sessionItem.ID())
289}
290
291// ShortHelp implements [help.KeyMap].
292func (s *Session) ShortHelp() []key.Binding {
293	switch s.sessionsMode {
294	case sessionsModeDeleting:
295		return []key.Binding{
296			s.keyMap.ConfirmDelete,
297			s.keyMap.CancelDelete,
298		}
299	default:
300		return []key.Binding{
301			s.keyMap.UpDown,
302			s.keyMap.Select,
303			s.keyMap.Delete,
304			s.keyMap.Close,
305		}
306	}
307}
308
309// FullHelp implements [help.KeyMap].
310func (s *Session) FullHelp() [][]key.Binding {
311	m := [][]key.Binding{}
312	slice := []key.Binding{
313		s.keyMap.Select,
314		s.keyMap.Next,
315		s.keyMap.Previous,
316		s.keyMap.Delete,
317		s.keyMap.Close,
318	}
319
320	switch s.sessionsMode {
321	case sessionsModeDeleting:
322		slice = []key.Binding{
323			s.keyMap.ConfirmDelete,
324			s.keyMap.CancelDelete,
325		}
326	}
327	for i := 0; i < len(slice); i += 4 {
328		end := min(i+4, len(slice))
329		m = append(m, slice[i:end])
330	}
331	return m
332}