on main help sign in
feat: delete sessions (#1963)
abbee92Kujtim Hoxha
internal/agent/common_test.go+1−1
@@ -112,7 +112,7 @@ func testEnv(t *testing.T) fakeEnv {
112 require.NoError(t, err)
112 require.NoError(t, err)
113
113
114 q := db.New(conn)
114 q := db.New(conn)
115− sessions := session.NewService(q)
115+ sessions := session.NewService(q, conn)
116 messages := message.NewService(q)
116 messages := message.NewService(q)
117
117
118 permissions := permission.NewPermissionService(workingDir, true, []string{})
118 permissions := permission.NewPermissionService(workingDir, true, []string{})
internal/app/app.go+1−1
@@ -73,7 +73,7 @@ type App struct {
73// New initializes a new application instance.
73// New initializes a new application instance.
74func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
74func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
75 q := db.New(conn)
75 q := db.New(conn)
76− sessions := session.NewService(q)
76+ sessions := session.NewService(q, conn)
77 messages := message.NewService(q)
77 messages := message.NewService(q)
78 files := history.NewService(q, conn)
78 files := history.NewService(q, conn)
79 skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests
79 skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests
internal/session/session.go+27−7
@@ -61,7 +61,8 @@ type Service interface {
61
61
62type service struct {
62type service struct {
63 *pubsub.Broker[Session]
63 *pubsub.Broker[Session]
64− q db.Querier
64+ db *sql.DB
65+ q *db.Queries
65}
66}
66
67
67func (s *service) Create(ctx context.Context, title string) (Session, error) {
68func (s *service) Create(ctx context.Context, title string) (Session, error) {
@@ -107,14 +108,32 @@ func (s *service) CreateTitleSession(ctx context.Context, parentSessionID string
107}
108}
108
109
109func (s *service) Delete(ctx context.Context, id string) error {
110func (s *service) Delete(ctx context.Context, id string) error {
110− session, err := s.Get(ctx, id)
111+ tx, err := s.db.BeginTx(ctx, nil)
111 if err != nil {
112 if err != nil {
112− return err
113+ return fmt.Errorf("beginning transaction: %w", err)
113 }
114 }
114− err = s.q.DeleteSession(ctx, session.ID)
115+ defer tx.Rollback() //nolint:errcheck
116+
117+ qtx := s.q.WithTx(tx)
118+
119+ dbSession, err := qtx.GetSessionByID(ctx, id)
115 if err != nil {
120 if err != nil {
116 return err
121 return err
117 }
122 }
123+ if err = qtx.DeleteSessionMessages(ctx, dbSession.ID); err != nil {
124+ return fmt.Errorf("deleting session messages: %w", err)
125+ }
126+ if err = qtx.DeleteSessionFiles(ctx, dbSession.ID); err != nil {
127+ return fmt.Errorf("deleting session files: %w", err)
128+ }
129+ if err = qtx.DeleteSession(ctx, dbSession.ID); err != nil {
130+ return fmt.Errorf("deleting session: %w", err)
131+ }
132+ if err = tx.Commit(); err != nil {
133+ return fmt.Errorf("committing transaction: %w", err)
134+ }
135+
136+ session := s.fromDBItem(dbSession)
118 s.Publish(pubsub.DeletedEvent, session)
137 s.Publish(pubsub.DeletedEvent, session)
119 event.SessionDeleted()
138 event.SessionDeleted()
120 return nil
139 return nil
@@ -223,11 +242,12 @@ func unmarshalTodos(data string) ([]Todo, error) {
223 return todos, nil
242 return todos, nil
224}
243}
225
244
226−func NewService(q db.Querier) Service {
245+func NewService(q *db.Queries, conn *sql.DB) Service {
227 broker := pubsub.NewBroker[Session]()
246 broker := pubsub.NewBroker[Session]()
228 return &service{
247 return &service{
229− broker,
248+ Broker: broker,
249+ db: conn,
230− q,
250+ q: q,
231 }
251 }
232}
252}
233
253
internal/ui/common/elements.go+2−2
@@ -177,13 +177,13 @@ func Section(t *styles.Styles, text string, width int, info ...string) string {
177
177
178// DialogTitle renders a dialog title with a decorative line filling the
178// DialogTitle renders a dialog title with a decorative line filling the
179// remaining width.
179// remaining width.
180−func DialogTitle(t *styles.Styles, title string, width int) string {
180+func DialogTitle(t *styles.Styles, title string, width int, fromColor, toColor color.Color) string {
181 char := "╱"
181 char := "╱"
182 length := lipgloss.Width(title) + 1
182 length := lipgloss.Width(title) + 1
183 remainingWidth := width - length
183 remainingWidth := width - length
184 if remainingWidth > 0 {
184 if remainingWidth > 0 {
185 lines := strings.Repeat(char, remainingWidth)
185 lines := strings.Repeat(char, remainingWidth)
186− lines = styles.ApplyForegroundGrad(t, lines, t.Primary, t.Secondary)
186+ lines = styles.ApplyForegroundGrad(t, lines, fromColor, toColor)
187 title = title + " " + lines
187 title = title + " " + lines
188 }
188 }
189 return title
189 return title
internal/ui/dialog/api_key_input.go+1−1
@@ -207,7 +207,7 @@ func (m *APIKeyInput) headerView() string {
207 return textStyle.Render(m.dialogTitle())
207 return textStyle.Render(m.dialogTitle())
208 }
208 }
209 headerOffset := titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
209 headerOffset := titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
210− return common.DialogTitle(t, titleStyle.Render(m.dialogTitle()), m.width-headerOffset)
210+ return common.DialogTitle(t, titleStyle.Render(m.dialogTitle()), m.width-headerOffset, m.com.Styles.Primary, m.com.Styles.Secondary)
211}
211}
212
212
213func (m *APIKeyInput) dialogTitle() string {
213func (m *APIKeyInput) dialogTitle() string {
internal/ui/dialog/arguments.go+1−1
@@ -316,7 +316,7 @@ func (a *Arguments) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
316 titleText = "Arguments"
316 titleText = "Arguments"
317 }
317 }
318
318
319− header := common.DialogTitle(s, titleText, width)
319+ header := common.DialogTitle(s, titleText, width, s.Primary, s.Secondary)
320
320
321 // Add description if available.
321 // Add description if available.
322 var description string
322 var description string
internal/ui/dialog/commands.go+1−1
@@ -387,7 +387,7 @@ func (c *Commands) setCommandItems(commandType CommandType) {
387func (c *Commands) defaultCommands() []*CommandItem {
387func (c *Commands) defaultCommands() []*CommandItem {
388 commands := []*CommandItem{
388 commands := []*CommandItem{
389 NewCommandItem(c.com.Styles, "new_session", "New Session", "ctrl+n", ActionNewSession{}),
389 NewCommandItem(c.com.Styles, "new_session", "New Session", "ctrl+n", ActionNewSession{}),
390− NewCommandItem(c.com.Styles, "switch_session", "Switch Session", "ctrl+s", ActionOpenDialog{SessionsID}),
390+ NewCommandItem(c.com.Styles, "switch_session", "Sessions", "ctrl+s", ActionOpenDialog{SessionsID}),
391 NewCommandItem(c.com.Styles, "switch_model", "Switch Model", "ctrl+l", ActionOpenDialog{ModelsID}),
391 NewCommandItem(c.com.Styles, "switch_model", "Switch Model", "ctrl+l", ActionOpenDialog{ModelsID}),
392 }
392 }
393
393
internal/ui/dialog/commands_item.go+7−1
@@ -66,5 +66,11 @@ func (c *CommandItem) Shortcut() string {
66
66
67// Render implements ListItem.
67// Render implements ListItem.
68func (c *CommandItem) Render(width int) string {
68func (c *CommandItem) Render(width int) string {
69+ styles := ListIemStyles{
70+ ItemBlurred: c.t.Dialog.NormalItem,
71+ ItemFocused: c.t.Dialog.SelectedItem,
72+ InfoTextBlurred: c.t.Base,
73+ InfoTextFocused: c.t.Subtle,
74+ }
69− return renderItem(c.t, c.title, c.shortcut, c.focused, width, c.cache, &c.m)
75+ return renderItem(styles, c.title, c.shortcut, c.focused, width, c.cache, &c.m)
70}
76}
internal/ui/dialog/common.go+19−6
@@ -1,6 +1,7 @@
1package dialog
1package dialog
2
2
3import (
3import (
4+ "image/color"
4 "strings"
5 "strings"
5
6
6 tea "charm.land/bubbletea/v2"
7 tea "charm.land/bubbletea/v2"
@@ -42,6 +43,14 @@ func InputCursor(t *styles.Styles, cur *tea.Cursor) *tea.Cursor {
42type RenderContext struct {
43type RenderContext struct {
43 // Styles is the styles to use for rendering.
44 // Styles is the styles to use for rendering.
44 Styles *styles.Styles
45 Styles *styles.Styles
46+ // TitleStyle is the style of the dialog title by default it uses Styles.Dialog.Title
47+ TitleStyle lipgloss.Style
48+ // ViewStyle is the style of the dialog title by default it uses Styles.Dialog.View
49+ ViewStyle lipgloss.Style
50+ // TitleGradientFromColor is the color the title gradient starts by defaults its Style.Primary
51+ TitleGradientFromColor color.Color
52+ // TitleGradientToColor is the color the title gradient starts by defaults its Style.Secondary
53+ TitleGradientToColor color.Color
45 // Width is the total width of the dialog including any margins, borders,
54 // Width is the total width of the dialog including any margins, borders,
46 // and paddings.
55 // and paddings.
47 Width int
56 Width int
@@ -68,9 +77,13 @@ type RenderContext struct {
68// NewRenderContext creates a new RenderContext with the provided styles and width.
77// NewRenderContext creates a new RenderContext with the provided styles and width.
69func NewRenderContext(t *styles.Styles, width int) *RenderContext {
78func NewRenderContext(t *styles.Styles, width int) *RenderContext {
70 return &RenderContext{
79 return &RenderContext{
71− Styles: t,
80+ Styles: t,
81+ TitleStyle: t.Dialog.Title,
82+ ViewStyle: t.Dialog.View,
83+ TitleGradientFromColor: t.Primary,
84+ TitleGradientToColor: t.Secondary,
72− Width: width,
85+ Width: width,
73− Parts: []string{},
86+ Parts: []string{},
74 }
87 }
75}
88}
76
89
@@ -83,8 +96,8 @@ func (rc *RenderContext) AddPart(part string) {
83
96
84// Render renders the dialog using the provided context.
97// Render renders the dialog using the provided context.
85func (rc *RenderContext) Render() string {
98func (rc *RenderContext) Render() string {
86− titleStyle := rc.Styles.Dialog.Title
99+ titleStyle := rc.TitleStyle
87− dialogStyle := rc.Styles.Dialog.View.Width(rc.Width)
100+ dialogStyle := rc.ViewStyle.Width(rc.Width)
88
101
89 var parts []string
102 var parts []string
90
103
@@ -96,7 +109,7 @@ func (rc *RenderContext) Render() string {
96 title := common.DialogTitle(rc.Styles, rc.Title,
109 title := common.DialogTitle(rc.Styles, rc.Title,
97 max(0, rc.Width-dialogStyle.GetHorizontalFrameSize()-
110 max(0, rc.Width-dialogStyle.GetHorizontalFrameSize()-
98 titleStyle.GetHorizontalFrameSize()-
111 titleStyle.GetHorizontalFrameSize()-
99− titleInfoWidth))
112+ titleInfoWidth), rc.TitleGradientFromColor, rc.TitleGradientToColor)
100 if len(rc.TitleInfo) > 0 {
113 if len(rc.TitleInfo) > 0 {
101 title += rc.TitleInfo
114 title += rc.TitleInfo
102 }
115 }
internal/ui/dialog/models_item.go+7−1
@@ -106,7 +106,13 @@ func (m *ModelItem) Render(width int) string {
106 if m.showProvider {
106 if m.showProvider {
107 providerInfo = string(m.prov.Name)
107 providerInfo = string(m.prov.Name)
108 }
108 }
109+ styles := ListIemStyles{
110+ ItemBlurred: m.t.Dialog.NormalItem,
111+ ItemFocused: m.t.Dialog.SelectedItem,
112+ InfoTextBlurred: m.t.Base,
113+ InfoTextFocused: m.t.Subtle,
114+ }
109− return renderItem(m.t, m.model.Name, providerInfo, m.focused, width, m.cache, &m.m)
115+ return renderItem(styles, m.model.Name, providerInfo, m.focused, width, m.cache, &m.m)
110}
116}
111
117
112// SetFocused implements ListItem.
118// SetFocused implements ListItem.
internal/ui/dialog/oauth.go+1−1
@@ -227,7 +227,7 @@ func (m *OAuth) headerContent() string {
227 if m.isOnboarding {
227 if m.isOnboarding {
228 return textStyle.Render(dialogTitle)
228 return textStyle.Render(dialogTitle)
229 }
229 }
230− return common.DialogTitle(t, titleStyle.Render(dialogTitle), m.width-headerOffset)
230+ return common.DialogTitle(t, titleStyle.Render(dialogTitle), m.width-headerOffset, t.Primary, t.Secondary)
231}
231}
232
232
233func (m *OAuth) innerDialogContent() string {
233func (m *OAuth) innerDialogContent() string {
internal/ui/dialog/permissions.go+1−1
@@ -410,7 +410,7 @@ func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
410func (p *Permissions) renderHeader(contentWidth int) string {
410func (p *Permissions) renderHeader(contentWidth int) string {
411 t := p.com.Styles
411 t := p.com.Styles
412
412
413− title := common.DialogTitle(t, "Permission Required", contentWidth-t.Dialog.Title.GetHorizontalFrameSize())
413+ title := common.DialogTitle(t, "Permission Required", contentWidth-t.Dialog.Title.GetHorizontalFrameSize(), t.Primary, t.Secondary)
414 title = t.Dialog.Title.Render(title)
414 title = t.Dialog.Title.Render(title)
415
415
416 // Tool info.
416 // Tool info.
internal/ui/dialog/reasoning.go+7−1
@@ -293,5 +293,11 @@ func (r *ReasoningItem) Render(width int) string {
293 if r.isCurrent {
293 if r.isCurrent {
294 info = "current"
294 info = "current"
295 }
295 }
296+ styles := ListIemStyles{
297+ ItemBlurred: r.t.Dialog.NormalItem,
298+ ItemFocused: r.t.Dialog.SelectedItem,
299+ InfoTextBlurred: r.t.Base,
300+ InfoTextFocused: r.t.Subtle,
301+ }
296− return renderItem(r.t, r.title, info, r.focused, width, r.cache, &r.m)
302+ return renderItem(styles, r.title, info, r.focused, width, r.cache, &r.m)
297}
303}
internal/ui/dialog/sessions.go+176−45
@@ -7,14 +7,25 @@ import (
7 "charm.land/bubbles/v2/key"
7 "charm.land/bubbles/v2/key"
8 "charm.land/bubbles/v2/textinput"
8 "charm.land/bubbles/v2/textinput"
9 tea "charm.land/bubbletea/v2"
9 tea "charm.land/bubbletea/v2"
10+ "github.com/charmbracelet/crush/internal/session"
10 "github.com/charmbracelet/crush/internal/ui/common"
11 "github.com/charmbracelet/crush/internal/ui/common"
11 "github.com/charmbracelet/crush/internal/ui/list"
12 "github.com/charmbracelet/crush/internal/ui/list"
13+ "github.com/charmbracelet/crush/internal/uiutil"
12 uv "github.com/charmbracelet/ultraviolet"
14 uv "github.com/charmbracelet/ultraviolet"
13)
15)
14
16
15// SessionsID is the identifier for the session selector dialog.
17// SessionsID is the identifier for the session selector dialog.
16const SessionsID = "session"
18const SessionsID = "session"
17
19
20+type sessionsMode uint8
21+
22+// Possible modes a session item can be in
23+const (
24+ sessionsModeNormal sessionsMode = iota
25+ sessionsModeDeleting
26+ sessionsModeUpdating
27+)
28+
18// Session is a session selector dialog.
29// Session is a session selector dialog.
19type Session struct {
30type Session struct {
20 com *common.Common
31 com *common.Common
@@ -22,13 +33,19 @@ type Session struct {
22 list *list.FilterableList
33 list *list.FilterableList
23 input textinput.Model
34 input textinput.Model
24 selectedSessionInx int
35 selectedSessionInx int
36+ sessions []session.Session
37+
38+ sessionsMode sessionsMode
25
39
26 keyMap struct {
40 keyMap struct {
27− Select key.Binding
41+ Select key.Binding
28− Next key.Binding
42+ Next key.Binding
29− Previous key.Binding
43+ Previous key.Binding
44+ UpDown key.Binding
45+ Delete key.Binding
46+ ConfirmDelete key.Binding
30− UpDown key.Binding
47+ CancelDelete key.Binding
31− Close key.Binding
48+ Close key.Binding
32 }
49 }
33}
50}
34
51
@@ -37,12 +54,14 @@ var _ Dialog = (*Session)(nil)
37// NewSessions creates a new Session dialog.
54// NewSessions creates a new Session dialog.
38func NewSessions(com *common.Common, selectedSessionID string) (*Session, error) {
55func NewSessions(com *common.Common, selectedSessionID string) (*Session, error) {
39 s := new(Session)
56 s := new(Session)
57+ s.sessionsMode = sessionsModeNormal
40 s.com = com
58 s.com = com
41 sessions, err := com.App.Sessions.List(context.TODO())
59 sessions, err := com.App.Sessions.List(context.TODO())
42 if err != nil {
60 if err != nil {
43 return nil, err
61 return nil, err
44 }
62 }
45
63
64+ s.sessions = sessions
46 for i, sess := range sessions {
65 for i, sess := range sessions {
47 if sess.ID == selectedSessionID {
66 if sess.ID == selectedSessionID {
48 s.selectedSessionInx = i
67 s.selectedSessionInx = i
@@ -54,7 +73,7 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error)
54 help.Styles = com.Styles.DialogHelpStyles()
73 help.Styles = com.Styles.DialogHelpStyles()
55
74
56 s.help = help
75 s.help = help
57− s.list = list.NewFilterableList(sessionItems(com.Styles, sessions...)...)
76+ s.list = list.NewFilterableList(sessionItems(com.Styles, sessionsModeNormal, sessions...)...)
58 s.list.Focus()
77 s.list.Focus()
59 s.list.SetSelected(s.selectedSessionInx)
78 s.list.SetSelected(s.selectedSessionInx)
60
79
@@ -80,6 +99,18 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error)
80 key.WithKeys("up", "down"),
99 key.WithKeys("up", "down"),
81 key.WithHelp("↑↓", "choose"),
100 key.WithHelp("↑↓", "choose"),
82 )
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+ )
83 s.keyMap.Close = CloseKey
114 s.keyMap.Close = CloseKey
84
115
85 return s, nil
116 return s, nil
@@ -94,40 +125,57 @@ func (s *Session) ID() string {
94func (s *Session) HandleMsg(msg tea.Msg) Action {
125func (s *Session) HandleMsg(msg tea.Msg) Action {
95 switch msg := msg.(type) {
126 switch msg := msg.(type) {
96 case tea.KeyPressMsg:
127 case tea.KeyPressMsg:
97− switch {
128+ switch s.sessionsMode {
129+ case sessionsModeDeleting:
130+ switch {
98− case key.Matches(msg, s.keyMap.Close):
131+ case key.Matches(msg, s.keyMap.ConfirmDelete):
99− return ActionClose{}
132+ return s.confirmDeleteSession()
100− case key.Matches(msg, s.keyMap.Previous):
133+ case key.Matches(msg, s.keyMap.CancelDelete):
101− s.list.Focus()
134+ s.sessionsMode = sessionsModeNormal
102− if s.list.IsSelectedFirst() {
135+ s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...)
103− s.list.SelectLast()
104− s.list.ScrollToBottom()
105− break
106 }
136 }
107− s.list.SelectPrev()
137+ default:
108− s.list.ScrollToSelected()
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()
109− case key.Matches(msg, s.keyMap.Next):
156+ case key.Matches(msg, s.keyMap.Next):
110− s.list.Focus()
157+ s.list.Focus()
111− if s.list.IsSelectedLast() {
158+ if s.list.IsSelectedLast() {
112− s.list.SelectFirst()
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)
113 s.list.ScrollToTop()
175 s.list.ScrollToTop()
114− break
176+ s.list.SetSelected(0)
177+ return ActionCmd{cmd}
115 }
178 }
116− s.list.SelectNext()
117− s.list.ScrollToSelected()
118− case key.Matches(msg, s.keyMap.Select):
119− if item := s.list.SelectedItem(); item != nil {
120− sessionItem := item.(*SessionItem)
121− return ActionSelectSession{sessionItem.Session}
122− }
123− default:
124− var cmd tea.Cmd
125− s.input, cmd = s.input.Update(msg)
126− value := s.input.Value()
127− s.list.SetFilter(value)
128− s.list.ScrollToTop()
129− s.list.SetSelected(0)
130− return ActionCmd{cmd}
131 }
179 }
132 }
180 }
133 return nil
181 return nil
@@ -160,27 +208,101 @@ func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
160 s.list.ScrollToSelected()
208 s.list.ScrollToSelected()
161 }
209 }
162
210
211+ var cur *tea.Cursor
163 rc := NewRenderContext(t, width)
212 rc := NewRenderContext(t, width)
164− rc.Title = "Switch Session"
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:
165− inputView := t.Dialog.InputPrompt.Render(s.input.View())
222+ inputView := t.Dialog.InputPrompt.Render(s.input.View())
223+ cur = s.Cursor()
166− rc.AddPart(inputView)
224+ rc.AddPart(inputView)
225+ }
167 listView := t.Dialog.List.Height(s.list.Height()).Render(s.list.Render())
226 listView := t.Dialog.List.Height(s.list.Height()).Render(s.list.Render())
168 rc.AddPart(listView)
227 rc.AddPart(listView)
169 rc.Help = s.help.View(s)
228 rc.Help = s.help.View(s)
170
229
171 view := rc.Render()
230 view := rc.Render()
172
231
173− cur := s.Cursor()
174 DrawCenterCursor(scr, area, view, cur)
232 DrawCenterCursor(scr, area, view, cur)
175 return cur
233 return cur
176}
234}
177
235
236+func (s *Session) selectedSessionItem() *SessionItem {
237+ if item := s.list.SelectedItem(); item != nil {
238+ return item.(*SessionItem)
239+ }
240+ return nil
241+}
242+
243+func (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+
254+func (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+
268+func (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+
278+func (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+
178// ShortHelp implements [help.KeyMap].
291// ShortHelp implements [help.KeyMap].
179func (s *Session) ShortHelp() []key.Binding {
292func (s *Session) ShortHelp() []key.Binding {
293+ switch s.sessionsMode {
294+ case sessionsModeDeleting:
180− return []key.Binding{
295+ return []key.Binding{
296+ s.keyMap.ConfirmDelete,
297+ s.keyMap.CancelDelete,
298+ }
299+ default:
300+ return []key.Binding{
181− s.keyMap.UpDown,
301+ s.keyMap.UpDown,
182− s.keyMap.Select,
302+ s.keyMap.Select,
303+ s.keyMap.Delete,
183− s.keyMap.Close,
304+ s.keyMap.Close,
305+ }
184 }
306 }
185}
307}
186
308
@@ -191,8 +313,17 @@ func (s *Session) FullHelp() [][]key.Binding {
191 s.keyMap.Select,
313 s.keyMap.Select,
192 s.keyMap.Next,
314 s.keyMap.Next,
193 s.keyMap.Previous,
315 s.keyMap.Previous,
316+ s.keyMap.Delete,
194 s.keyMap.Close,
317 s.keyMap.Close,
195 }
318 }
319+
320+ switch s.sessionsMode {
321+ case sessionsModeDeleting:
322+ slice = []key.Binding{
323+ s.keyMap.ConfirmDelete,
324+ s.keyMap.CancelDelete,
325+ }
326+ }
196 for i := 0; i < len(slice); i += 4 {
327 for i := 0; i < len(slice); i += 4 {
197 end := min(i+4, len(slice))
328 end := min(i+4, len(slice))
198 m = append(m, slice[i:end])
329 m = append(m, slice[i:end])
internal/ui/dialog/sessions_item.go+32−12
@@ -28,10 +28,11 @@ type ListItem interface {
28// SessionItem wraps a [session.Session] to implement the [ListItem] interface.
28// SessionItem wraps a [session.Session] to implement the [ListItem] interface.
29type SessionItem struct {
29type SessionItem struct {
30 session.Session
30 session.Session
31− t *styles.Styles
31+ t *styles.Styles
32+ sessionsMode sessionsMode
32− m fuzzy.Match
33+ m fuzzy.Match
33− cache map[int]string
34+ cache map[int]string
34− focused bool
35+ focused bool
35}
36}
36
37
37var _ ListItem = &SessionItem{}
38var _ ListItem = &SessionItem{}
@@ -55,10 +56,29 @@ func (s *SessionItem) SetMatch(m fuzzy.Match) {
55// Render returns the string representation of the session item.
56// Render returns the string representation of the session item.
56func (s *SessionItem) Render(width int) string {
57func (s *SessionItem) Render(width int) string {
57 info := humanize.Time(time.Unix(s.UpdatedAt, 0))
58 info := humanize.Time(time.Unix(s.UpdatedAt, 0))
59+ styles := ListIemStyles{
60+ ItemBlurred: s.t.Dialog.NormalItem,
61+ ItemFocused: s.t.Dialog.SelectedItem,
62+ InfoTextBlurred: s.t.Subtle,
63+ InfoTextFocused: s.t.Base,
64+ }
65+
66+ switch s.sessionsMode {
67+ case sessionsModeDeleting:
68+ styles.ItemBlurred = s.t.Dialog.Sessions.DeletingItemBlurred
69+ styles.ItemFocused = s.t.Dialog.Sessions.DeletingItemFocused
70+ }
58− return renderItem(s.t, s.Title, info, s.focused, width, s.cache, &s.m)
71+ return renderItem(styles, s.Title, info, s.focused, width, s.cache, &s.m)
72+}
73+
74+type ListIemStyles struct {
75+ ItemBlurred lipgloss.Style
76+ ItemFocused lipgloss.Style
77+ InfoTextBlurred lipgloss.Style
78+ InfoTextFocused lipgloss.Style
59}
79}
60
80
61−func renderItem(t *styles.Styles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {
81+func renderItem(t ListIemStyles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {
62 if cache == nil {
82 if cache == nil {
63 cache = make(map[int]string)
83 cache = make(map[int]string)
64 }
84 }
@@ -68,9 +88,9 @@ func renderItem(t *styles.Styles, title string, info string, focused bool, width
68 return cached
88 return cached
69 }
89 }
70
90
71− style := t.Dialog.NormalItem
91+ style := t.ItemBlurred
72 if focused {
92 if focused {
73− style = t.Dialog.SelectedItem
93+ style = t.ItemFocused
74 }
94 }
75
95
76 var infoText string
96 var infoText string
@@ -79,9 +99,9 @@ func renderItem(t *styles.Styles, title string, info string, focused bool, width
79 if len(info) > 0 {
99 if len(info) > 0 {
80 infoText = fmt.Sprintf(" %s ", info)
100 infoText = fmt.Sprintf(" %s ", info)
81 if focused {
101 if focused {
82− infoText = t.Base.Render(infoText)
102+ infoText = t.InfoTextFocused.Render(infoText)
83 } else {
103 } else {
84− infoText = t.Subtle.Render(infoText)
104+ infoText = t.InfoTextBlurred.Render(infoText)
85 }
105 }
86
106
87 infoWidth = lipgloss.Width(infoText)
107 infoWidth = lipgloss.Width(infoText)
@@ -134,10 +154,10 @@ func (s *SessionItem) SetFocused(focused bool) {
134
154
135// sessionItems takes a slice of [session.Session]s and convert them to a slice
155// sessionItems takes a slice of [session.Session]s and convert them to a slice
136// of [ListItem]s.
156// of [ListItem]s.
137−func sessionItems(t *styles.Styles, sessions ...session.Session) []list.FilterableItem {
157+func sessionItems(t *styles.Styles, mode sessionsMode, sessions ...session.Session) []list.FilterableItem {
138 items := make([]list.FilterableItem, len(sessions))
158 items := make([]list.FilterableItem, len(sessions))
139 for i, s := range sessions {
159 for i, s := range sessions {
140− items[i] = &SessionItem{Session: s, t: t}
160+ items[i] = &SessionItem{Session: s, t: t, sessionsMode: mode}
141 }
161 }
142 return items
162 return items
143}
163}
internal/ui/model/ui.go+6
@@ -412,6 +412,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
412 m.dialog.CloseFrontDialog()
412 m.dialog.CloseFrontDialog()
413
413
414 case pubsub.Event[session.Session]:
414 case pubsub.Event[session.Session]:
415+ if msg.Type == pubsub.DeletedEvent {
416+ if m.session != nil && m.session.ID == msg.Payload.ID {
417+ m.newSession()
418+ }
419+ break
420+ }
415 if m.session != nil && msg.Payload.ID == m.session.ID {
421 if m.session != nil && msg.Payload.ID == m.session.ID {
416 prevHasInProgress := hasInProgressTodo(m.session.Todos)
422 prevHasInProgress := hasInProgressTodo(m.session.Todos)
417 m.session = &msg.Payload
423 m.session = &msg.Payload
internal/ui/styles/styles.go+19
@@ -338,6 +338,7 @@ type Styles struct {
338 FullDesc lipgloss.Style
338 FullDesc lipgloss.Style
339 FullSeparator lipgloss.Style
339 FullSeparator lipgloss.Style
340 }
340 }
341+
341 NormalItem lipgloss.Style
342 NormalItem lipgloss.Style
342 SelectedItem lipgloss.Style
343 SelectedItem lipgloss.Style
343 InputPrompt lipgloss.Style
344 InputPrompt lipgloss.Style
@@ -366,6 +367,16 @@ type Styles struct {
366 Commands struct{}
367 Commands struct{}
367
368
368 ImagePreview lipgloss.Style
369 ImagePreview lipgloss.Style
370+
371+ Sessions struct {
372+ DeletingView lipgloss.Style
373+ DeletingItemFocused lipgloss.Style
374+ DeletingItemBlurred lipgloss.Style
375+ DeletingTitle lipgloss.Style
376+ DeletingMessage lipgloss.Style
377+ DeletingTitleGradientFromColor color.Color
378+ DeletingTitleGradientToColor color.Color
379+ }
369 }
380 }
370
381
371 // Status bar and help
382 // Status bar and help
@@ -1268,6 +1279,14 @@ func DefaultStyles() Styles {
1268 s.Dialog.Arguments.InputRequiredMarkBlurred = base.Foreground(fgMuted).SetString("*")
1279 s.Dialog.Arguments.InputRequiredMarkBlurred = base.Foreground(fgMuted).SetString("*")
1269 s.Dialog.Arguments.InputRequiredMarkFocused = base.Foreground(primary).Bold(true).SetString("*")
1280 s.Dialog.Arguments.InputRequiredMarkFocused = base.Foreground(primary).Bold(true).SetString("*")
1270
1281
1282+ s.Dialog.Sessions.DeletingTitle = s.Dialog.Title.Foreground(red)
1283+ s.Dialog.Sessions.DeletingView = s.Dialog.View.BorderForeground(red)
1284+ s.Dialog.Sessions.DeletingMessage = s.Base.Padding(1)
1285+ s.Dialog.Sessions.DeletingTitleGradientFromColor = red
1286+ s.Dialog.Sessions.DeletingTitleGradientToColor = s.Primary
1287+ s.Dialog.Sessions.DeletingItemBlurred = s.Dialog.NormalItem.Foreground(fgSubtle)
1288+ s.Dialog.Sessions.DeletingItemFocused = s.Dialog.SelectedItem.Background(red)
1289+
1271 s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
1290 s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
1272 s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")
1291 s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")
1273 s.Status.InfoIndicator = s.Status.SuccessIndicator
1292 s.Status.InfoIndicator = s.Status.SuccessIndicator