summaryrefslogtreecommitdiffhomepage
path: root/packages/tui/internal
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-06-24 11:07:41 -0500
committerGitHub <[email protected]>2025-06-24 11:07:41 -0500
commit6f1847542891421e2be44218c7e31fb329582452 (patch)
tree96c65ea0c708954fecc8bcad18c2e8757a6255f4 /packages/tui/internal
parent3664b09812352795fc9855b9a921fdd2ca293a14 (diff)
downloadopencode-6f1847542891421e2be44218c7e31fb329582452.tar.gz
opencode-6f1847542891421e2be44218c7e31fb329582452.zip
feat: delete sessions (#362)
Co-authored-by: adamdottv <[email protected]>
Diffstat (limited to 'packages/tui/internal')
-rw-r--r--packages/tui/internal/app/app.go13
-rw-r--r--packages/tui/internal/components/dialog/session.go162
-rw-r--r--packages/tui/internal/tui/tui.go12
3 files changed, 170 insertions, 17 deletions
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)
}