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.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{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	s.list.SetSize(innerWidth, height-heightOffset)
241	s.help.SetWidth(innerWidth)
242
243	// This makes it so we do not scroll the list if we don't have to
244	start, end := s.list.VisibleItemIndices()
245
246	// if selected index is outside visible range, scroll to it
247	if s.selectedSessionInx < start || s.selectedSessionInx > end {
248		s.list.ScrollToSelected()
249	}
250
251	var cur *tea.Cursor
252	rc := NewRenderContext(t, width)
253	rc.Title = "Sessions"
254	switch s.sessionsMode {
255	case sessionsModeDeleting:
256		rc.TitleStyle = t.Dialog.Sessions.DeletingTitle
257		rc.TitleGradientFromColor = t.Dialog.Sessions.DeletingTitleGradientFromColor
258		rc.TitleGradientToColor = t.Dialog.Sessions.DeletingTitleGradientToColor
259		rc.ViewStyle = t.Dialog.Sessions.DeletingView
260		rc.AddPart(t.Dialog.Sessions.DeletingMessage.Render("Delete this session?"))
261	case sessionsModeUpdating:
262		rc.TitleStyle = t.Dialog.Sessions.RenamingingTitle
263		rc.TitleGradientFromColor = t.Dialog.Sessions.RenamingTitleGradientFromColor
264		rc.TitleGradientToColor = t.Dialog.Sessions.RenamingTitleGradientToColor
265		rc.ViewStyle = t.Dialog.Sessions.RenamingView
266		message := t.Dialog.Sessions.RenamingingMessage.Render("Rename this session?")
267		rc.AddPart(message)
268		item := s.selectedSessionItem()
269		if item == nil {
270			return nil
271		}
272		cur = item.Cursor()
273		if cur == nil {
274			break
275		}
276
277		start, end := s.list.VisibleItemIndices()
278		selectedIndex := s.list.Selected()
279
280		titleStyle := t.Dialog.Sessions.RenamingingTitle
281		dialogStyle := t.Dialog.Sessions.RenamingView
282		inputStyle := t.Dialog.InputPrompt
283
284		// Adjust cursor position to account for dialog layout + message
285		cur.X += inputStyle.GetBorderLeftSize() +
286			inputStyle.GetMarginLeft() +
287			inputStyle.GetPaddingLeft() +
288			dialogStyle.GetBorderLeftSize() +
289			dialogStyle.GetPaddingLeft() +
290			dialogStyle.GetMarginLeft()
291		cur.Y += titleStyle.GetVerticalFrameSize() +
292			inputStyle.GetBorderTopSize() +
293			inputStyle.GetMarginTop() +
294			inputStyle.GetPaddingTop() +
295			inputStyle.GetBorderBottomSize() +
296			inputStyle.GetMarginBottom() +
297			inputStyle.GetPaddingBottom() +
298			dialogStyle.GetPaddingTop() +
299			dialogStyle.GetBorderTopSize() +
300			lipgloss.Height(message) - 1
301
302		// move the cursor by one down until we see the selectedIndex
303		for ; start <= end && start != selectedIndex && selectedIndex > -1; start++ {
304			cur.Y += 1
305		}
306	default:
307		inputView := t.Dialog.InputPrompt.Render(s.input.View())
308		cur = s.Cursor()
309		rc.AddPart(inputView)
310	}
311	listView := t.Dialog.List.Height(s.list.Height()).Render(s.list.Render())
312	rc.AddPart(listView)
313	rc.Help = s.help.View(s)
314
315	view := rc.Render()
316
317	DrawCenterCursor(scr, area, view, cur)
318	return cur
319}
320
321func (s *Session) selectedSessionItem() *SessionItem {
322	if item := s.list.SelectedItem(); item != nil {
323		return item.(*SessionItem)
324	}
325	return nil
326}
327
328func (s *Session) confirmDeleteSession() Action {
329	sessionItem := s.selectedSessionItem()
330	s.sessionsMode = sessionsModeNormal
331	if sessionItem == nil {
332		return nil
333	}
334
335	s.removeSession(sessionItem.ID())
336	return ActionCmd{s.deleteSessionCmd(sessionItem.ID())}
337}
338
339func (s *Session) removeSession(id string) {
340	var newSessions []session.Session
341	for _, sess := range s.sessions {
342		if sess.ID == id {
343			continue
344		}
345		newSessions = append(newSessions, sess)
346	}
347	s.sessions = newSessions
348}
349
350func (s *Session) deleteSessionCmd(id string) tea.Cmd {
351	return func() tea.Msg {
352		err := s.com.App.Sessions.Delete(context.TODO(), id)
353		if err != nil {
354			return util.NewErrorMsg(err)
355		}
356		return nil
357	}
358}
359
360func (s *Session) confirmRenameSession() Action {
361	sessionItem := s.selectedSessionItem()
362	s.sessionsMode = sessionsModeNormal
363	if sessionItem == nil {
364		return nil
365	}
366
367	newTitle := strings.TrimSpace(sessionItem.InputValue())
368	if newTitle == "" {
369		return nil
370	}
371	session := sessionItem.Session
372	session.Title = newTitle
373	s.updateSession(session)
374	return ActionCmd{s.updateSessionCmd(session)}
375}
376
377func (s *Session) updateSession(session session.Session) {
378	for existingID, sess := range s.sessions {
379		if sess.ID == session.ID {
380			s.sessions[existingID] = session
381			break
382		}
383	}
384}
385
386func (s *Session) updateSessionCmd(session session.Session) tea.Cmd {
387	return func() tea.Msg {
388		_, err := s.com.App.Sessions.Save(context.TODO(), session)
389		if err != nil {
390			return util.NewErrorMsg(err)
391		}
392		return nil
393	}
394}
395
396func (s *Session) isCurrentSessionBusy() bool {
397	sessionItem := s.selectedSessionItem()
398	if sessionItem == nil {
399		return false
400	}
401
402	if s.com.App.AgentCoordinator == nil {
403		return false
404	}
405
406	return s.com.App.AgentCoordinator.IsSessionBusy(sessionItem.ID())
407}
408
409// ShortHelp implements [help.KeyMap].
410func (s *Session) ShortHelp() []key.Binding {
411	switch s.sessionsMode {
412	case sessionsModeDeleting:
413		return []key.Binding{
414			s.keyMap.ConfirmDelete,
415			s.keyMap.CancelDelete,
416		}
417	case sessionsModeUpdating:
418		return []key.Binding{
419			s.keyMap.ConfirmRename,
420			s.keyMap.CancelRename,
421		}
422	default:
423		return []key.Binding{
424			s.keyMap.UpDown,
425			s.keyMap.Rename,
426			s.keyMap.Delete,
427			s.keyMap.Select,
428			s.keyMap.Close,
429		}
430	}
431}
432
433// FullHelp implements [help.KeyMap].
434func (s *Session) FullHelp() [][]key.Binding {
435	m := [][]key.Binding{}
436	slice := []key.Binding{
437		s.keyMap.UpDown,
438		s.keyMap.Rename,
439		s.keyMap.Delete,
440		s.keyMap.Select,
441		s.keyMap.Close,
442	}
443
444	switch s.sessionsMode {
445	case sessionsModeDeleting:
446		slice = []key.Binding{
447			s.keyMap.ConfirmDelete,
448			s.keyMap.CancelDelete,
449		}
450	case sessionsModeUpdating:
451		slice = []key.Binding{
452			s.keyMap.ConfirmRename,
453			s.keyMap.CancelRename,
454		}
455	}
456	for i := 0; i < len(slice); i += 4 {
457		end := min(i+4, len(slice))
458		m = append(m, slice[i:end])
459	}
460	return m
461}