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}