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}