From 6f1847542891421e2be44218c7e31fb329582452 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 24 Jun 2025 11:07:41 -0500 Subject: feat: delete sessions (#362) Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com> --- packages/tui/internal/app/app.go | 13 ++ packages/tui/internal/components/dialog/session.go | 162 +++++++++++++++++++-- packages/tui/internal/tui/tui.go | 12 +- 3 files changed, 170 insertions(+), 17 deletions(-) (limited to 'packages/tui/internal') diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 63c398c43..4c156b68d 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -396,6 +396,19 @@ func (a *App) ListSessions(ctx context.Context) ([]client.SessionInfo, error) { return sessions, nil } +func (a *App) DeleteSession(ctx context.Context, sessionID string) error { + resp, err := a.Client.PostSessionDeleteWithResponse(ctx, client.PostSessionDeleteJSONRequestBody{ + SessionID: sessionID, + }) + if err != nil { + return err + } + if resp.StatusCode() != 200 { + return fmt.Errorf("failed to delete session: %d", resp.StatusCode()) + } + return nil +} + func (a *App) ListMessages(ctx context.Context, sessionId string) ([]client.MessageInfo, error) { resp, err := a.Client.PostSessionMessagesWithResponse(ctx, client.PostSessionMessagesJSONRequestBody{SessionID: sessionId}) if err != nil { diff --git a/packages/tui/internal/components/dialog/session.go b/packages/tui/internal/components/dialog/session.go index 2887ae736..71f3f2e9e 100644 --- a/packages/tui/internal/components/dialog/session.go +++ b/packages/tui/internal/components/dialog/session.go @@ -2,12 +2,20 @@ package dialog import ( "context" + "strings" + + "slices" tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" + "github.com/muesli/reflow/truncate" "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/components/list" "github.com/sst/opencode/internal/components/modal" + "github.com/sst/opencode/internal/components/toast" "github.com/sst/opencode/internal/layout" + "github.com/sst/opencode/internal/styles" + "github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/util" "github.com/sst/opencode/pkg/client" ) @@ -17,12 +25,65 @@ type SessionDialog interface { layout.Modal } +// sessionItem is a custom list item for sessions that can show delete confirmation +type sessionItem struct { + title string + isDeleteConfirming bool +} + +func (s sessionItem) Render(selected bool, width int) string { + t := theme.CurrentTheme() + baseStyle := styles.BaseStyle() + + var text string + if s.isDeleteConfirming { + text = "Press again to confirm delete" + } else { + text = s.title + } + + truncatedStr := truncate.StringWithTail(text, uint(width-1), "...") + + var itemStyle lipgloss.Style + if selected { + if s.isDeleteConfirming { + // Red background for delete confirmation + itemStyle = baseStyle. + Background(t.Error()). + Foreground(t.Background()). + Width(width). + PaddingLeft(1) + } else { + // Normal selection + itemStyle = baseStyle. + Background(t.Primary()). + Foreground(t.Background()). + Width(width). + PaddingLeft(1) + } + } else { + if s.isDeleteConfirming { + // Red text for delete confirmation when not selected + itemStyle = baseStyle. + Foreground(t.Error()). + PaddingLeft(1) + } else { + itemStyle = baseStyle. + PaddingLeft(1) + } + } + + return itemStyle.Render(truncatedStr) +} + type sessionDialog struct { - width int - height int - modal *modal.Modal - sessions []client.SessionInfo - list list.List[list.StringItem] + width int + height int + modal *modal.Modal + sessions []client.SessionInfo + list list.List[sessionItem] + app *app.App + deleteConfirmation int // -1 means no confirmation, >= 0 means confirming deletion of session at this index } func (s *sessionDialog) Init() tea.Cmd { @@ -38,6 +99,11 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyPressMsg: switch msg.String() { case "enter": + if s.deleteConfirmation >= 0 { + s.deleteConfirmation = -1 + s.updateListItems() + return s, nil + } if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) { selectedSession := s.sessions[idx] return s, tea.Sequence( @@ -45,17 +111,79 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { util.CmdHandler(app.SessionSelectedMsg(&selectedSession)), ) } + case "x", "delete", "backspace": + if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) { + if s.deleteConfirmation == idx { + // Second press - actually delete the session + sessionToDelete := s.sessions[idx] + return s, tea.Sequence( + func() tea.Msg { + s.sessions = slices.Delete(s.sessions, idx, idx+1) + s.deleteConfirmation = -1 + s.updateListItems() + return nil + }, + s.deleteSession(sessionToDelete.Id), + ) + } else { + // First press - enter delete confirmation mode + s.deleteConfirmation = idx + s.updateListItems() + return s, nil + } + } + case "esc": + if s.deleteConfirmation >= 0 { + s.deleteConfirmation = -1 + s.updateListItems() + return s, nil + } } } var cmd tea.Cmd listModel, cmd := s.list.Update(msg) - s.list = listModel.(list.List[list.StringItem]) + s.list = listModel.(list.List[sessionItem]) return s, cmd } func (s *sessionDialog) Render(background string) string { - return s.modal.Render(s.list.View(), background) + listView := s.list.View() + + t := theme.CurrentTheme() + helpStyle := styles.BaseStyle().PaddingLeft(1).PaddingTop(1) + helpText := styles.BaseStyle().Foreground(t.Text()).Render("x/del") + helpText = helpText + styles.BaseStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Render(" delete session") + helpText = helpStyle.Render(helpText) + + content := strings.Join([]string{listView, helpText}, "\n") + + return s.modal.Render(content, background) +} + +func (s *sessionDialog) updateListItems() { + _, currentIdx := s.list.GetSelectedItem() + + var items []sessionItem + for i, sess := range s.sessions { + item := sessionItem{ + title: sess.Title, + isDeleteConfirming: s.deleteConfirmation == i, + } + items = append(items, item) + } + s.list.SetItems(items) + s.list.SetSelectedIndex(currentIdx) +} + +func (s *sessionDialog) deleteSession(sessionID string) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + if err := s.app.DeleteSession(ctx, sessionID); err != nil { + return toast.NewErrorToast("Failed to delete session: " + err.Error())() + } + return nil + } } func (s *sessionDialog) Close() tea.Cmd { @@ -67,26 +195,32 @@ func NewSessionDialog(app *app.App) SessionDialog { sessions, _ := app.ListSessions(context.Background()) var filteredSessions []client.SessionInfo - var sessionTitles []string + var items []sessionItem for _, sess := range sessions { if sess.ParentID != nil { continue } filteredSessions = append(filteredSessions, sess) - sessionTitles = append(sessionTitles, sess.Title) + items = append(items, sessionItem{ + title: sess.Title, + isDeleteConfirming: false, + }) } - list := list.NewStringList( - sessionTitles, + // Create a generic list component + listComponent := list.NewListComponent( + items, 10, // maxVisibleSessions "No sessions available", true, // useAlphaNumericKeys ) - list.SetMaxWidth(layout.Current.Container.Width - 12) + listComponent.SetMaxWidth(layout.Current.Container.Width - 12) return &sessionDialog{ - sessions: filteredSessions, - list: list, + sessions: filteredSessions, + list: listComponent, + app: app, + deleteConfirmation: -1, modal: modal.New( modal.WithTitle("Switch Session"), modal.WithMaxWidth(layout.Current.Container.Width-8), diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 70b8ff605..503af9fee 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -261,6 +261,12 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { "opencode updated to "+msg.Properties.Version+", restart to apply.", toast.WithTitle("New version installed"), ) + case client.EventSessionDeleted: + if a.app.Session != nil && msg.Properties.Info.Id == a.app.Session.Id { + a.app.Session = &client.SessionInfo{} + a.app.Messages = []client.MessageInfo{} + } + return a, toast.NewSuccessToast("Session deleted successfully") case client.EventSessionUpdated: if msg.Properties.Info.Id == a.app.Session.Id { a.app.Session = &msg.Properties.Info @@ -269,7 +275,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Properties.Info.Metadata.SessionID == a.app.Session.Id { exists := false optimisticReplaced := false - + // First check if this is replacing an optimistic message if msg.Properties.Info.Role == client.User { // Look for optimistic messages to replace @@ -283,7 +289,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } - + // If not replacing optimistic, check for existing message with same ID if !optimisticReplaced { for i, m := range a.app.Messages { @@ -294,7 +300,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } - + if !exists { a.app.Messages = append(a.app.Messages, msg.Properties.Info) } -- cgit v1.2.3