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}