feat: add session deletion with confirmation dialog

Kieran Klukas and Crush created

- Add delete key binding (d/delete) to sessions dialog
- Implement confirmation dialog for session deletion
- Handle session deletion in main TUI with proper cleanup
- Clear current session if deleted session was active
- Close sessions dialog after successful deletion

🐾 Generated with Crush
Co-Authored-By: Crush <crush@charm.land>

Change summary

internal/tui/components/chat/chat.go                 |  13 +
internal/tui/components/dialogs/sessions/delete.go   | 160 ++++++++++++++
internal/tui/components/dialogs/sessions/keys.go     |   8 
internal/tui/components/dialogs/sessions/sessions.go |   9 
internal/tui/tui.go                                  |  11 
5 files changed, 200 insertions(+), 1 deletion(-)

Detailed changes

internal/tui/components/chat/chat.go 🔗

@@ -27,6 +27,10 @@ type SessionSelectedMsg = session.Session
 
 type SessionClearedMsg struct{}
 
+type SessionDeletedMsg struct {
+	Session session.Session
+}
+
 const (
 	NotFound = -1
 )
@@ -91,6 +95,15 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.session = session.Session{}
 		return m, m.listCmp.SetItems([]util.Model{})
 
+	case SessionDeletedMsg:
+		if msg.Session.ID == m.session.ID {
+			m.session = session.Session{}
+			return m, tea.Batch(
+				m.listCmp.SetItems([]util.Model{}),
+				func() tea.Msg { return SessionClearedMsg{} },
+			)
+		}
+		return m, nil
 	case pubsub.Event[message.Message]:
 		cmd := m.handleMessageEvent(msg)
 		return m, cmd

internal/tui/components/dialogs/sessions/delete.go 🔗

@@ -0,0 +1,160 @@
+package sessions
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/tui/components/chat"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/lipgloss/v2"
+)
+
+const DeleteSessionDialogID dialogs.DialogID = "delete-session"
+
+type DeleteSessionDialog interface {
+	dialogs.DialogModel
+}
+
+type deleteSessionDialogCmp struct {
+	wWidth     int
+	wHeight    int
+	session    session.Session
+	selectedNo bool
+	keymap     DeleteKeyMap
+}
+
+type DeleteKeyMap struct {
+	LeftRight,
+	EnterSpace,
+	Yes,
+	No,
+	Tab,
+	Close key.Binding
+}
+
+func DefaultDeleteKeymap() DeleteKeyMap {
+	return DeleteKeyMap{
+		LeftRight: key.NewBinding(
+			key.WithKeys("left", "right"),
+			key.WithHelp("←/→", "switch options"),
+		),
+		EnterSpace: key.NewBinding(
+			key.WithKeys("enter", " "),
+			key.WithHelp("enter/space", "confirm"),
+		),
+		Yes: key.NewBinding(
+			key.WithKeys("y", "Y"),
+			key.WithHelp("y/Y", "yes"),
+		),
+		No: key.NewBinding(
+			key.WithKeys("n", "N"),
+			key.WithHelp("n/N", "no"),
+		),
+		Tab: key.NewBinding(
+			key.WithKeys("tab"),
+			key.WithHelp("tab", "switch options"),
+		),
+		Close: key.NewBinding(
+			key.WithKeys("esc"),
+			key.WithHelp("esc", "cancel"),
+		),
+	}
+}
+
+func NewDeleteSessionDialog(session session.Session) DeleteSessionDialog {
+	return &deleteSessionDialogCmp{
+		session:    session,
+		selectedNo: true,
+		keymap:     DefaultDeleteKeymap(),
+	}
+}
+
+func (d *deleteSessionDialogCmp) Init() tea.Cmd {
+	return nil
+}
+
+func (d *deleteSessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		d.wWidth = msg.Width
+		d.wHeight = msg.Height
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, d.keymap.LeftRight, d.keymap.Tab):
+			d.selectedNo = !d.selectedNo
+			return d, nil
+		case key.Matches(msg, d.keymap.EnterSpace):
+			if !d.selectedNo {
+				return d, tea.Sequence(
+					util.CmdHandler(dialogs.CloseDialogMsg{}),
+					util.CmdHandler(chat.SessionDeletedMsg{Session: d.session}),
+				)
+			}
+			return d, util.CmdHandler(dialogs.CloseDialogMsg{})
+		case key.Matches(msg, d.keymap.Yes):
+			return d, tea.Sequence(
+				util.CmdHandler(dialogs.CloseDialogMsg{}),
+				util.CmdHandler(chat.SessionDeletedMsg{Session: d.session}),
+			)
+		case key.Matches(msg, d.keymap.No, d.keymap.Close):
+			return d, util.CmdHandler(dialogs.CloseDialogMsg{})
+		}
+	}
+	return d, nil
+}
+
+func (d *deleteSessionDialogCmp) View() string {
+	t := styles.CurrentTheme()
+	baseStyle := t.S().Base
+	yesStyle := t.S().Text
+	noStyle := yesStyle
+
+	if d.selectedNo {
+		noStyle = noStyle.Foreground(t.White).Background(t.Secondary)
+		yesStyle = yesStyle.Background(t.BgSubtle)
+	} else {
+		yesStyle = yesStyle.Foreground(t.White).Background(t.Secondary)
+		noStyle = noStyle.Background(t.BgSubtle)
+	}
+
+	question := "Delete session \"" + d.session.Title + "\"?"
+	const horizontalPadding = 3
+	yesButton := yesStyle.Padding(0, horizontalPadding).Render("Delete")
+	noButton := noStyle.Padding(0, horizontalPadding).Render("Cancel")
+
+	buttons := baseStyle.Width(lipgloss.Width(question)).Align(lipgloss.Right).Render(
+		lipgloss.JoinHorizontal(lipgloss.Center, yesButton, "  ", noButton),
+	)
+
+	content := baseStyle.Render(
+		lipgloss.JoinVertical(
+			lipgloss.Center,
+			question,
+			"",
+			buttons,
+		),
+	)
+
+	deleteDialogStyle := baseStyle.
+		Padding(1, 2).
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(t.BorderFocus)
+
+	return deleteDialogStyle.Render(content)
+}
+
+func (d *deleteSessionDialogCmp) Position() (int, int) {
+	question := "Delete session \"" + d.session.Title + "\"?"
+	row := d.wHeight / 2
+	row -= 7 / 2
+	col := d.wWidth / 2
+	col -= (lipgloss.Width(question) + 4) / 2
+
+	return row, col
+}
+
+func (d *deleteSessionDialogCmp) ID() dialogs.DialogID {
+	return DeleteSessionDialogID
+}

internal/tui/components/dialogs/sessions/keys.go 🔗

@@ -8,6 +8,7 @@ type KeyMap struct {
 	Select,
 	Next,
 	Previous,
+	Delete,
 	Close key.Binding
 }
 
@@ -25,6 +26,10 @@ func DefaultKeyMap() KeyMap {
 			key.WithKeys("up", "ctrl+p"),
 			key.WithHelp("↑", "previous item"),
 		),
+		Delete: key.NewBinding(
+			key.WithKeys("delete", "d"),
+			key.WithHelp("d/del", "delete session"),
+		),
 		Close: key.NewBinding(
 			key.WithKeys("esc"),
 			key.WithHelp("esc", "cancel"),
@@ -38,6 +43,7 @@ func (k KeyMap) KeyBindings() []key.Binding {
 		k.Select,
 		k.Next,
 		k.Previous,
+		k.Delete,
 		k.Close,
 	}
 }
@@ -57,11 +63,11 @@ func (k KeyMap) FullHelp() [][]key.Binding {
 func (k KeyMap) ShortHelp() []key.Binding {
 	return []key.Binding{
 		key.NewBinding(
-
 			key.WithKeys("down", "up"),
 			key.WithHelp("↑↓", "choose"),
 		),
 		k.Select,
+		k.Delete,
 		k.Close,
 	}
 }

internal/tui/components/dialogs/sessions/sessions.go 🔗

@@ -111,6 +111,15 @@ func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 					),
 				)
 			}
+		case key.Matches(msg, s.keyMap.Delete):
+			if len(s.sessionsList.Items()) > 0 {
+				items := s.sessionsList.Items()
+				selectedItemInx := s.sessionsList.SelectedIndex()
+				selectedSession := items[selectedItemInx].(completions.CompletionItem).Value().(session.Session)
+				return s, util.CmdHandler(dialogs.OpenDialogMsg{
+					Model: NewDeleteSessionDialog(selectedSession),
+				})
+			}
 		case key.Matches(msg, s.keyMap.Close):
 			return s, util.CmdHandler(dialogs.CloseDialogMsg{})
 		default:

internal/tui/tui.go 🔗

@@ -134,6 +134,17 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	// Session
 	case cmpChat.SessionSelectedMsg:
 		a.selectedSessionID = msg.ID
+	case cmpChat.SessionDeletedMsg:
+		err := a.app.Sessions.Delete(context.Background(), msg.Session.ID)
+		if err != nil {
+			return a, util.ReportError(err)
+		}
+
+		if msg.Session.ID == a.selectedSessionID {
+			a.selectedSessionID = ""
+		}
+
+		return a, util.CmdHandler(dialogs.CloseDialogMsg{})
 	case cmpChat.SessionClearedMsg:
 		a.selectedSessionID = ""
 	// Commands