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}