feat: delete sessions (#1963)

Kujtim Hoxha created

* feat: delete sessions

* chore: small cleanup

* chore: make delete transactional

* refactor: cleanup the delete logic

Change summary

internal/agent/common_test.go       |   2 
internal/app/app.go                 |   2 
internal/session/session.go         |  34 +++
internal/ui/common/elements.go      |   4 
internal/ui/dialog/api_key_input.go |   2 
internal/ui/dialog/arguments.go     |   2 
internal/ui/dialog/commands.go      |   2 
internal/ui/dialog/commands_item.go |   8 
internal/ui/dialog/common.go        |  25 ++
internal/ui/dialog/models_item.go   |   8 
internal/ui/dialog/oauth.go         |   2 
internal/ui/dialog/permissions.go   |   2 
internal/ui/dialog/reasoning.go     |   8 
internal/ui/dialog/sessions.go      | 221 ++++++++++++++++++++++++------
internal/ui/dialog/sessions_item.go |  44 ++++-
internal/ui/model/ui.go             |   6 
internal/ui/styles/styles.go        |  19 ++
17 files changed, 309 insertions(+), 82 deletions(-)

Detailed changes

internal/agent/common_test.go 🔗

@@ -112,7 +112,7 @@ func testEnv(t *testing.T) fakeEnv {
 	require.NoError(t, err)
 
 	q := db.New(conn)
-	sessions := session.NewService(q)
+	sessions := session.NewService(q, conn)
 	messages := message.NewService(q)
 
 	permissions := permission.NewPermissionService(workingDir, true, []string{})

internal/app/app.go 🔗

@@ -73,7 +73,7 @@ type App struct {
 // New initializes a new application instance.
 func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
 	q := db.New(conn)
-	sessions := session.NewService(q)
+	sessions := session.NewService(q, conn)
 	messages := message.NewService(q)
 	files := history.NewService(q, conn)
 	skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests

internal/session/session.go 🔗

@@ -61,7 +61,8 @@ type Service interface {
 
 type service struct {
 	*pubsub.Broker[Session]
-	q db.Querier
+	db *sql.DB
+	q  *db.Queries
 }
 
 func (s *service) Create(ctx context.Context, title string) (Session, error) {
@@ -107,14 +108,32 @@ func (s *service) CreateTitleSession(ctx context.Context, parentSessionID string
 }
 
 func (s *service) Delete(ctx context.Context, id string) error {
-	session, err := s.Get(ctx, id)
+	tx, err := s.db.BeginTx(ctx, nil)
 	if err != nil {
-		return err
+		return fmt.Errorf("beginning transaction: %w", err)
 	}
-	err = s.q.DeleteSession(ctx, session.ID)
+	defer tx.Rollback() //nolint:errcheck
+
+	qtx := s.q.WithTx(tx)
+
+	dbSession, err := qtx.GetSessionByID(ctx, id)
 	if err != nil {
 		return err
 	}
+	if err = qtx.DeleteSessionMessages(ctx, dbSession.ID); err != nil {
+		return fmt.Errorf("deleting session messages: %w", err)
+	}
+	if err = qtx.DeleteSessionFiles(ctx, dbSession.ID); err != nil {
+		return fmt.Errorf("deleting session files: %w", err)
+	}
+	if err = qtx.DeleteSession(ctx, dbSession.ID); err != nil {
+		return fmt.Errorf("deleting session: %w", err)
+	}
+	if err = tx.Commit(); err != nil {
+		return fmt.Errorf("committing transaction: %w", err)
+	}
+
+	session := s.fromDBItem(dbSession)
 	s.Publish(pubsub.DeletedEvent, session)
 	event.SessionDeleted()
 	return nil
@@ -223,11 +242,12 @@ func unmarshalTodos(data string) ([]Todo, error) {
 	return todos, nil
 }
 
-func NewService(q db.Querier) Service {
+func NewService(q *db.Queries, conn *sql.DB) Service {
 	broker := pubsub.NewBroker[Session]()
 	return &service{
-		broker,
-		q,
+		Broker: broker,
+		db:     conn,
+		q:      q,
 	}
 }
 

internal/ui/common/elements.go 🔗

@@ -177,13 +177,13 @@ func Section(t *styles.Styles, text string, width int, info ...string) string {
 
 // DialogTitle renders a dialog title with a decorative line filling the
 // remaining width.
-func DialogTitle(t *styles.Styles, title string, width int) string {
+func DialogTitle(t *styles.Styles, title string, width int, fromColor, toColor color.Color) string {
 	char := "╱"
 	length := lipgloss.Width(title) + 1
 	remainingWidth := width - length
 	if remainingWidth > 0 {
 		lines := strings.Repeat(char, remainingWidth)
-		lines = styles.ApplyForegroundGrad(t, lines, t.Primary, t.Secondary)
+		lines = styles.ApplyForegroundGrad(t, lines, fromColor, toColor)
 		title = title + " " + lines
 	}
 	return title

internal/ui/dialog/api_key_input.go 🔗

@@ -207,7 +207,7 @@ func (m *APIKeyInput) headerView() string {
 		return textStyle.Render(m.dialogTitle())
 	}
 	headerOffset := titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
-	return common.DialogTitle(t, titleStyle.Render(m.dialogTitle()), m.width-headerOffset)
+	return common.DialogTitle(t, titleStyle.Render(m.dialogTitle()), m.width-headerOffset, m.com.Styles.Primary, m.com.Styles.Secondary)
 }
 
 func (m *APIKeyInput) dialogTitle() string {

internal/ui/dialog/arguments.go 🔗

@@ -316,7 +316,7 @@ func (a *Arguments) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 		titleText = "Arguments"
 	}
 
-	header := common.DialogTitle(s, titleText, width)
+	header := common.DialogTitle(s, titleText, width, s.Primary, s.Secondary)
 
 	// Add description if available.
 	var description string

internal/ui/dialog/commands.go 🔗

@@ -387,7 +387,7 @@ func (c *Commands) setCommandItems(commandType CommandType) {
 func (c *Commands) defaultCommands() []*CommandItem {
 	commands := []*CommandItem{
 		NewCommandItem(c.com.Styles, "new_session", "New Session", "ctrl+n", ActionNewSession{}),
-		NewCommandItem(c.com.Styles, "switch_session", "Switch Session", "ctrl+s", ActionOpenDialog{SessionsID}),
+		NewCommandItem(c.com.Styles, "switch_session", "Sessions", "ctrl+s", ActionOpenDialog{SessionsID}),
 		NewCommandItem(c.com.Styles, "switch_model", "Switch Model", "ctrl+l", ActionOpenDialog{ModelsID}),
 	}
 

internal/ui/dialog/commands_item.go 🔗

@@ -66,5 +66,11 @@ func (c *CommandItem) Shortcut() string {
 
 // Render implements ListItem.
 func (c *CommandItem) Render(width int) string {
-	return renderItem(c.t, c.title, c.shortcut, c.focused, width, c.cache, &c.m)
+	styles := ListIemStyles{
+		ItemBlurred:     c.t.Dialog.NormalItem,
+		ItemFocused:     c.t.Dialog.SelectedItem,
+		InfoTextBlurred: c.t.Base,
+		InfoTextFocused: c.t.Subtle,
+	}
+	return renderItem(styles, c.title, c.shortcut, c.focused, width, c.cache, &c.m)
 }

internal/ui/dialog/common.go 🔗

@@ -1,6 +1,7 @@
 package dialog
 
 import (
+	"image/color"
 	"strings"
 
 	tea "charm.land/bubbletea/v2"
@@ -42,6 +43,14 @@ func InputCursor(t *styles.Styles, cur *tea.Cursor) *tea.Cursor {
 type RenderContext struct {
 	// Styles is the styles to use for rendering.
 	Styles *styles.Styles
+	// TitleStyle is the style of the dialog title by default it uses Styles.Dialog.Title
+	TitleStyle lipgloss.Style
+	// ViewStyle is the style of the dialog title by default it uses Styles.Dialog.View
+	ViewStyle lipgloss.Style
+	// TitleGradientFromColor is the color the title gradient starts by defaults its Style.Primary
+	TitleGradientFromColor color.Color
+	// TitleGradientToColor is the color the title gradient starts by defaults its Style.Secondary
+	TitleGradientToColor color.Color
 	// Width is the total width of the dialog including any margins, borders,
 	// and paddings.
 	Width int
@@ -68,9 +77,13 @@ type RenderContext struct {
 // NewRenderContext creates a new RenderContext with the provided styles and width.
 func NewRenderContext(t *styles.Styles, width int) *RenderContext {
 	return &RenderContext{
-		Styles: t,
-		Width:  width,
-		Parts:  []string{},
+		Styles:                 t,
+		TitleStyle:             t.Dialog.Title,
+		ViewStyle:              t.Dialog.View,
+		TitleGradientFromColor: t.Primary,
+		TitleGradientToColor:   t.Secondary,
+		Width:                  width,
+		Parts:                  []string{},
 	}
 }
 
@@ -83,8 +96,8 @@ func (rc *RenderContext) AddPart(part string) {
 
 // Render renders the dialog using the provided context.
 func (rc *RenderContext) Render() string {
-	titleStyle := rc.Styles.Dialog.Title
-	dialogStyle := rc.Styles.Dialog.View.Width(rc.Width)
+	titleStyle := rc.TitleStyle
+	dialogStyle := rc.ViewStyle.Width(rc.Width)
 
 	var parts []string
 
@@ -96,7 +109,7 @@ func (rc *RenderContext) Render() string {
 		title := common.DialogTitle(rc.Styles, rc.Title,
 			max(0, rc.Width-dialogStyle.GetHorizontalFrameSize()-
 				titleStyle.GetHorizontalFrameSize()-
-				titleInfoWidth))
+				titleInfoWidth), rc.TitleGradientFromColor, rc.TitleGradientToColor)
 		if len(rc.TitleInfo) > 0 {
 			title += rc.TitleInfo
 		}

internal/ui/dialog/models_item.go 🔗

@@ -106,7 +106,13 @@ func (m *ModelItem) Render(width int) string {
 	if m.showProvider {
 		providerInfo = string(m.prov.Name)
 	}
-	return renderItem(m.t, m.model.Name, providerInfo, m.focused, width, m.cache, &m.m)
+	styles := ListIemStyles{
+		ItemBlurred:     m.t.Dialog.NormalItem,
+		ItemFocused:     m.t.Dialog.SelectedItem,
+		InfoTextBlurred: m.t.Base,
+		InfoTextFocused: m.t.Subtle,
+	}
+	return renderItem(styles, m.model.Name, providerInfo, m.focused, width, m.cache, &m.m)
 }
 
 // SetFocused implements ListItem.

internal/ui/dialog/oauth.go 🔗

@@ -227,7 +227,7 @@ func (m *OAuth) headerContent() string {
 	if m.isOnboarding {
 		return textStyle.Render(dialogTitle)
 	}
-	return common.DialogTitle(t, titleStyle.Render(dialogTitle), m.width-headerOffset)
+	return common.DialogTitle(t, titleStyle.Render(dialogTitle), m.width-headerOffset, t.Primary, t.Secondary)
 }
 
 func (m *OAuth) innerDialogContent() string {

internal/ui/dialog/permissions.go 🔗

@@ -410,7 +410,7 @@ func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 func (p *Permissions) renderHeader(contentWidth int) string {
 	t := p.com.Styles
 
-	title := common.DialogTitle(t, "Permission Required", contentWidth-t.Dialog.Title.GetHorizontalFrameSize())
+	title := common.DialogTitle(t, "Permission Required", contentWidth-t.Dialog.Title.GetHorizontalFrameSize(), t.Primary, t.Secondary)
 	title = t.Dialog.Title.Render(title)
 
 	// Tool info.

internal/ui/dialog/reasoning.go 🔗

@@ -293,5 +293,11 @@ func (r *ReasoningItem) Render(width int) string {
 	if r.isCurrent {
 		info = "current"
 	}
-	return renderItem(r.t, r.title, info, r.focused, width, r.cache, &r.m)
+	styles := ListIemStyles{
+		ItemBlurred:     r.t.Dialog.NormalItem,
+		ItemFocused:     r.t.Dialog.SelectedItem,
+		InfoTextBlurred: r.t.Base,
+		InfoTextFocused: r.t.Subtle,
+	}
+	return renderItem(styles, r.title, info, r.focused, width, r.cache, &r.m)
 }

internal/ui/dialog/sessions.go 🔗

@@ -7,14 +7,25 @@ import (
 	"charm.land/bubbles/v2/key"
 	"charm.land/bubbles/v2/textinput"
 	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/list"
+	"github.com/charmbracelet/crush/internal/uiutil"
 	uv "github.com/charmbracelet/ultraviolet"
 )
 
 // SessionsID is the identifier for the session selector dialog.
 const SessionsID = "session"
 
+type sessionsMode uint8
+
+// Possible modes a session item can be in
+const (
+	sessionsModeNormal sessionsMode = iota
+	sessionsModeDeleting
+	sessionsModeUpdating
+)
+
 // Session is a session selector dialog.
 type Session struct {
 	com                *common.Common
@@ -22,13 +33,19 @@ type Session struct {
 	list               *list.FilterableList
 	input              textinput.Model
 	selectedSessionInx int
+	sessions           []session.Session
+
+	sessionsMode sessionsMode
 
 	keyMap struct {
-		Select   key.Binding
-		Next     key.Binding
-		Previous key.Binding
-		UpDown   key.Binding
-		Close    key.Binding
+		Select        key.Binding
+		Next          key.Binding
+		Previous      key.Binding
+		UpDown        key.Binding
+		Delete        key.Binding
+		ConfirmDelete key.Binding
+		CancelDelete  key.Binding
+		Close         key.Binding
 	}
 }
 
@@ -37,12 +54,14 @@ var _ Dialog = (*Session)(nil)
 // NewSessions creates a new Session dialog.
 func NewSessions(com *common.Common, selectedSessionID string) (*Session, error) {
 	s := new(Session)
+	s.sessionsMode = sessionsModeNormal
 	s.com = com
 	sessions, err := com.App.Sessions.List(context.TODO())
 	if err != nil {
 		return nil, err
 	}
 
+	s.sessions = sessions
 	for i, sess := range sessions {
 		if sess.ID == selectedSessionID {
 			s.selectedSessionInx = i
@@ -54,7 +73,7 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error)
 	help.Styles = com.Styles.DialogHelpStyles()
 
 	s.help = help
-	s.list = list.NewFilterableList(sessionItems(com.Styles, sessions...)...)
+	s.list = list.NewFilterableList(sessionItems(com.Styles, sessionsModeNormal, sessions...)...)
 	s.list.Focus()
 	s.list.SetSelected(s.selectedSessionInx)
 
@@ -80,6 +99,18 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error)
 		key.WithKeys("up", "down"),
 		key.WithHelp("↑↓", "choose"),
 	)
+	s.keyMap.Delete = key.NewBinding(
+		key.WithKeys("ctrl+x"),
+		key.WithHelp("ctrl+x", "delete"),
+	)
+	s.keyMap.ConfirmDelete = key.NewBinding(
+		key.WithKeys("y"),
+		key.WithHelp("y", "delete"),
+	)
+	s.keyMap.CancelDelete = key.NewBinding(
+		key.WithKeys("n", "esc"),
+		key.WithHelp("n", "cancel"),
+	)
 	s.keyMap.Close = CloseKey
 
 	return s, nil
@@ -94,40 +125,57 @@ func (s *Session) ID() string {
 func (s *Session) HandleMsg(msg tea.Msg) Action {
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
-		switch {
-		case key.Matches(msg, s.keyMap.Close):
-			return ActionClose{}
-		case key.Matches(msg, s.keyMap.Previous):
-			s.list.Focus()
-			if s.list.IsSelectedFirst() {
-				s.list.SelectLast()
-				s.list.ScrollToBottom()
-				break
+		switch s.sessionsMode {
+		case sessionsModeDeleting:
+			switch {
+			case key.Matches(msg, s.keyMap.ConfirmDelete):
+				return s.confirmDeleteSession()
+			case key.Matches(msg, s.keyMap.CancelDelete):
+				s.sessionsMode = sessionsModeNormal
+				s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...)
 			}
-			s.list.SelectPrev()
-			s.list.ScrollToSelected()
-		case key.Matches(msg, s.keyMap.Next):
-			s.list.Focus()
-			if s.list.IsSelectedLast() {
-				s.list.SelectFirst()
+		default:
+			switch {
+			case key.Matches(msg, s.keyMap.Close):
+				return ActionClose{}
+			case key.Matches(msg, s.keyMap.Delete):
+				if s.isCurrentSessionBusy() {
+					return ActionCmd{uiutil.ReportWarn("Agent is busy, please wait...")}
+				}
+				s.sessionsMode = sessionsModeDeleting
+				s.list.SetItems(sessionItems(s.com.Styles, sessionsModeDeleting, s.sessions...)...)
+			case key.Matches(msg, s.keyMap.Previous):
+				s.list.Focus()
+				if s.list.IsSelectedFirst() {
+					s.list.SelectLast()
+					s.list.ScrollToBottom()
+					break
+				}
+				s.list.SelectPrev()
+				s.list.ScrollToSelected()
+			case key.Matches(msg, s.keyMap.Next):
+				s.list.Focus()
+				if s.list.IsSelectedLast() {
+					s.list.SelectFirst()
+					s.list.ScrollToTop()
+					break
+				}
+				s.list.SelectNext()
+				s.list.ScrollToSelected()
+			case key.Matches(msg, s.keyMap.Select):
+				if item := s.list.SelectedItem(); item != nil {
+					sessionItem := item.(*SessionItem)
+					return ActionSelectSession{sessionItem.Session}
+				}
+			default:
+				var cmd tea.Cmd
+				s.input, cmd = s.input.Update(msg)
+				value := s.input.Value()
+				s.list.SetFilter(value)
 				s.list.ScrollToTop()
-				break
+				s.list.SetSelected(0)
+				return ActionCmd{cmd}
 			}
-			s.list.SelectNext()
-			s.list.ScrollToSelected()
-		case key.Matches(msg, s.keyMap.Select):
-			if item := s.list.SelectedItem(); item != nil {
-				sessionItem := item.(*SessionItem)
-				return ActionSelectSession{sessionItem.Session}
-			}
-		default:
-			var cmd tea.Cmd
-			s.input, cmd = s.input.Update(msg)
-			value := s.input.Value()
-			s.list.SetFilter(value)
-			s.list.ScrollToTop()
-			s.list.SetSelected(0)
-			return ActionCmd{cmd}
 		}
 	}
 	return nil
@@ -160,27 +208,101 @@ func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 		s.list.ScrollToSelected()
 	}
 
+	var cur *tea.Cursor
 	rc := NewRenderContext(t, width)
-	rc.Title = "Switch Session"
-	inputView := t.Dialog.InputPrompt.Render(s.input.View())
-	rc.AddPart(inputView)
+	rc.Title = "Sessions"
+	switch s.sessionsMode {
+	case sessionsModeDeleting:
+		rc.TitleStyle = t.Dialog.Sessions.DeletingTitle
+		rc.TitleGradientFromColor = t.Dialog.Sessions.DeletingTitleGradientFromColor
+		rc.TitleGradientToColor = t.Dialog.Sessions.DeletingTitleGradientToColor
+		rc.ViewStyle = t.Dialog.Sessions.DeletingView
+		rc.AddPart(t.Dialog.Sessions.DeletingMessage.Render("Delete this session?"))
+	default:
+		inputView := t.Dialog.InputPrompt.Render(s.input.View())
+		cur = s.Cursor()
+		rc.AddPart(inputView)
+	}
 	listView := t.Dialog.List.Height(s.list.Height()).Render(s.list.Render())
 	rc.AddPart(listView)
 	rc.Help = s.help.View(s)
 
 	view := rc.Render()
 
-	cur := s.Cursor()
 	DrawCenterCursor(scr, area, view, cur)
 	return cur
 }
 
+func (s *Session) selectedSessionItem() *SessionItem {
+	if item := s.list.SelectedItem(); item != nil {
+		return item.(*SessionItem)
+	}
+	return nil
+}
+
+func (s *Session) confirmDeleteSession() Action {
+	sessionItem := s.selectedSessionItem()
+	s.sessionsMode = sessionsModeNormal
+	if sessionItem == nil {
+		return nil
+	}
+
+	s.removeSession(sessionItem.ID())
+	return ActionCmd{s.deleteSessionCmd(sessionItem.ID())}
+}
+
+func (s *Session) removeSession(id string) {
+	var newSessions []session.Session
+	for _, sess := range s.sessions {
+		if sess.ID == id {
+			continue
+		}
+		newSessions = append(newSessions, sess)
+	}
+	s.sessions = newSessions
+	s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...)
+	s.list.SelectFirst()
+	s.list.ScrollToSelected()
+}
+
+func (s *Session) deleteSessionCmd(id string) tea.Cmd {
+	return func() tea.Msg {
+		err := s.com.App.Sessions.Delete(context.TODO(), id)
+		if err != nil {
+			return uiutil.NewErrorMsg(err)
+		}
+		return nil
+	}
+}
+
+func (s *Session) isCurrentSessionBusy() bool {
+	sessionItem := s.selectedSessionItem()
+	if sessionItem == nil {
+		return false
+	}
+
+	if s.com.App.AgentCoordinator == nil {
+		return false
+	}
+
+	return s.com.App.AgentCoordinator.IsSessionBusy(sessionItem.ID())
+}
+
 // ShortHelp implements [help.KeyMap].
 func (s *Session) ShortHelp() []key.Binding {
-	return []key.Binding{
-		s.keyMap.UpDown,
-		s.keyMap.Select,
-		s.keyMap.Close,
+	switch s.sessionsMode {
+	case sessionsModeDeleting:
+		return []key.Binding{
+			s.keyMap.ConfirmDelete,
+			s.keyMap.CancelDelete,
+		}
+	default:
+		return []key.Binding{
+			s.keyMap.UpDown,
+			s.keyMap.Select,
+			s.keyMap.Delete,
+			s.keyMap.Close,
+		}
 	}
 }
 
@@ -191,8 +313,17 @@ func (s *Session) FullHelp() [][]key.Binding {
 		s.keyMap.Select,
 		s.keyMap.Next,
 		s.keyMap.Previous,
+		s.keyMap.Delete,
 		s.keyMap.Close,
 	}
+
+	switch s.sessionsMode {
+	case sessionsModeDeleting:
+		slice = []key.Binding{
+			s.keyMap.ConfirmDelete,
+			s.keyMap.CancelDelete,
+		}
+	}
 	for i := 0; i < len(slice); i += 4 {
 		end := min(i+4, len(slice))
 		m = append(m, slice[i:end])

internal/ui/dialog/sessions_item.go 🔗

@@ -28,10 +28,11 @@ type ListItem interface {
 // SessionItem wraps a [session.Session] to implement the [ListItem] interface.
 type SessionItem struct {
 	session.Session
-	t       *styles.Styles
-	m       fuzzy.Match
-	cache   map[int]string
-	focused bool
+	t            *styles.Styles
+	sessionsMode sessionsMode
+	m            fuzzy.Match
+	cache        map[int]string
+	focused      bool
 }
 
 var _ ListItem = &SessionItem{}
@@ -55,10 +56,29 @@ func (s *SessionItem) SetMatch(m fuzzy.Match) {
 // Render returns the string representation of the session item.
 func (s *SessionItem) Render(width int) string {
 	info := humanize.Time(time.Unix(s.UpdatedAt, 0))
-	return renderItem(s.t, s.Title, info, s.focused, width, s.cache, &s.m)
+	styles := ListIemStyles{
+		ItemBlurred:     s.t.Dialog.NormalItem,
+		ItemFocused:     s.t.Dialog.SelectedItem,
+		InfoTextBlurred: s.t.Subtle,
+		InfoTextFocused: s.t.Base,
+	}
+
+	switch s.sessionsMode {
+	case sessionsModeDeleting:
+		styles.ItemBlurred = s.t.Dialog.Sessions.DeletingItemBlurred
+		styles.ItemFocused = s.t.Dialog.Sessions.DeletingItemFocused
+	}
+	return renderItem(styles, s.Title, info, s.focused, width, s.cache, &s.m)
+}
+
+type ListIemStyles struct {
+	ItemBlurred     lipgloss.Style
+	ItemFocused     lipgloss.Style
+	InfoTextBlurred lipgloss.Style
+	InfoTextFocused lipgloss.Style
 }
 
-func renderItem(t *styles.Styles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {
+func renderItem(t ListIemStyles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {
 	if cache == nil {
 		cache = make(map[int]string)
 	}
@@ -68,9 +88,9 @@ func renderItem(t *styles.Styles, title string, info string, focused bool, width
 		return cached
 	}
 
-	style := t.Dialog.NormalItem
+	style := t.ItemBlurred
 	if focused {
-		style = t.Dialog.SelectedItem
+		style = t.ItemFocused
 	}
 
 	var infoText string
@@ -79,9 +99,9 @@ func renderItem(t *styles.Styles, title string, info string, focused bool, width
 	if len(info) > 0 {
 		infoText = fmt.Sprintf(" %s ", info)
 		if focused {
-			infoText = t.Base.Render(infoText)
+			infoText = t.InfoTextFocused.Render(infoText)
 		} else {
-			infoText = t.Subtle.Render(infoText)
+			infoText = t.InfoTextBlurred.Render(infoText)
 		}
 
 		infoWidth = lipgloss.Width(infoText)
@@ -134,10 +154,10 @@ func (s *SessionItem) SetFocused(focused bool) {
 
 // sessionItems takes a slice of [session.Session]s and convert them to a slice
 // of [ListItem]s.
-func sessionItems(t *styles.Styles, sessions ...session.Session) []list.FilterableItem {
+func sessionItems(t *styles.Styles, mode sessionsMode, sessions ...session.Session) []list.FilterableItem {
 	items := make([]list.FilterableItem, len(sessions))
 	for i, s := range sessions {
-		items[i] = &SessionItem{Session: s, t: t}
+		items[i] = &SessionItem{Session: s, t: t, sessionsMode: mode}
 	}
 	return items
 }

internal/ui/model/ui.go 🔗

@@ -412,6 +412,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.dialog.CloseFrontDialog()
 
 	case pubsub.Event[session.Session]:
+		if msg.Type == pubsub.DeletedEvent {
+			if m.session != nil && m.session.ID == msg.Payload.ID {
+				m.newSession()
+			}
+			break
+		}
 		if m.session != nil && msg.Payload.ID == m.session.ID {
 			prevHasInProgress := hasInProgressTodo(m.session.Todos)
 			m.session = &msg.Payload

internal/ui/styles/styles.go 🔗

@@ -338,6 +338,7 @@ type Styles struct {
 			FullDesc       lipgloss.Style
 			FullSeparator  lipgloss.Style
 		}
+
 		NormalItem   lipgloss.Style
 		SelectedItem lipgloss.Style
 		InputPrompt  lipgloss.Style
@@ -366,6 +367,16 @@ type Styles struct {
 		Commands struct{}
 
 		ImagePreview lipgloss.Style
+
+		Sessions struct {
+			DeletingView                   lipgloss.Style
+			DeletingItemFocused            lipgloss.Style
+			DeletingItemBlurred            lipgloss.Style
+			DeletingTitle                  lipgloss.Style
+			DeletingMessage                lipgloss.Style
+			DeletingTitleGradientFromColor color.Color
+			DeletingTitleGradientToColor   color.Color
+		}
 	}
 
 	// Status bar and help
@@ -1268,6 +1279,14 @@ func DefaultStyles() Styles {
 	s.Dialog.Arguments.InputRequiredMarkBlurred = base.Foreground(fgMuted).SetString("*")
 	s.Dialog.Arguments.InputRequiredMarkFocused = base.Foreground(primary).Bold(true).SetString("*")
 
+	s.Dialog.Sessions.DeletingTitle = s.Dialog.Title.Foreground(red)
+	s.Dialog.Sessions.DeletingView = s.Dialog.View.BorderForeground(red)
+	s.Dialog.Sessions.DeletingMessage = s.Base.Padding(1)
+	s.Dialog.Sessions.DeletingTitleGradientFromColor = red
+	s.Dialog.Sessions.DeletingTitleGradientToColor = s.Primary
+	s.Dialog.Sessions.DeletingItemBlurred = s.Dialog.NormalItem.Foreground(fgSubtle)
+	s.Dialog.Sessions.DeletingItemFocused = s.Dialog.SelectedItem.Background(red)
+
 	s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
 	s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")
 	s.Status.InfoIndicator = s.Status.SuccessIndicator