session.go 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. package dialog
  2. import (
  3. "context"
  4. "strings"
  5. "slices"
  6. tea "github.com/charmbracelet/bubbletea/v2"
  7. "github.com/charmbracelet/lipgloss/v2"
  8. "github.com/muesli/reflow/truncate"
  9. "github.com/sst/opencode/internal/app"
  10. "github.com/sst/opencode/internal/components/list"
  11. "github.com/sst/opencode/internal/components/modal"
  12. "github.com/sst/opencode/internal/components/toast"
  13. "github.com/sst/opencode/internal/layout"
  14. "github.com/sst/opencode/internal/styles"
  15. "github.com/sst/opencode/internal/theme"
  16. "github.com/sst/opencode/internal/util"
  17. "github.com/sst/opencode/pkg/client"
  18. )
  19. // SessionDialog interface for the session switching dialog
  20. type SessionDialog interface {
  21. layout.Modal
  22. }
  23. // sessionItem is a custom list item for sessions that can show delete confirmation
  24. type sessionItem struct {
  25. title string
  26. isDeleteConfirming bool
  27. }
  28. func (s sessionItem) Render(selected bool, width int) string {
  29. t := theme.CurrentTheme()
  30. baseStyle := styles.BaseStyle()
  31. var text string
  32. if s.isDeleteConfirming {
  33. text = "Press again to confirm delete"
  34. } else {
  35. text = s.title
  36. }
  37. truncatedStr := truncate.StringWithTail(text, uint(width-1), "...")
  38. var itemStyle lipgloss.Style
  39. if selected {
  40. if s.isDeleteConfirming {
  41. // Red background for delete confirmation
  42. itemStyle = baseStyle.
  43. Background(t.Error()).
  44. Foreground(t.Background()).
  45. Width(width).
  46. PaddingLeft(1)
  47. } else {
  48. // Normal selection
  49. itemStyle = baseStyle.
  50. Background(t.Primary()).
  51. Foreground(t.Background()).
  52. Width(width).
  53. PaddingLeft(1)
  54. }
  55. } else {
  56. if s.isDeleteConfirming {
  57. // Red text for delete confirmation when not selected
  58. itemStyle = baseStyle.
  59. Foreground(t.Error()).
  60. PaddingLeft(1)
  61. } else {
  62. itemStyle = baseStyle.
  63. PaddingLeft(1)
  64. }
  65. }
  66. return itemStyle.Render(truncatedStr)
  67. }
  68. type sessionDialog struct {
  69. width int
  70. height int
  71. modal *modal.Modal
  72. sessions []client.SessionInfo
  73. list list.List[sessionItem]
  74. app *app.App
  75. deleteConfirmation int // -1 means no confirmation, >= 0 means confirming deletion of session at this index
  76. }
  77. func (s *sessionDialog) Init() tea.Cmd {
  78. return nil
  79. }
  80. func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  81. switch msg := msg.(type) {
  82. case tea.WindowSizeMsg:
  83. s.width = msg.Width
  84. s.height = msg.Height
  85. s.list.SetMaxWidth(layout.Current.Container.Width - 12)
  86. case tea.KeyPressMsg:
  87. switch msg.String() {
  88. case "enter":
  89. if s.deleteConfirmation >= 0 {
  90. s.deleteConfirmation = -1
  91. s.updateListItems()
  92. return s, nil
  93. }
  94. if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
  95. selectedSession := s.sessions[idx]
  96. return s, tea.Sequence(
  97. util.CmdHandler(modal.CloseModalMsg{}),
  98. util.CmdHandler(app.SessionSelectedMsg(&selectedSession)),
  99. )
  100. }
  101. case "x", "delete", "backspace":
  102. if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
  103. if s.deleteConfirmation == idx {
  104. // Second press - actually delete the session
  105. sessionToDelete := s.sessions[idx]
  106. return s, tea.Sequence(
  107. func() tea.Msg {
  108. s.sessions = slices.Delete(s.sessions, idx, idx+1)
  109. s.deleteConfirmation = -1
  110. s.updateListItems()
  111. return nil
  112. },
  113. s.deleteSession(sessionToDelete.Id),
  114. )
  115. } else {
  116. // First press - enter delete confirmation mode
  117. s.deleteConfirmation = idx
  118. s.updateListItems()
  119. return s, nil
  120. }
  121. }
  122. case "esc":
  123. if s.deleteConfirmation >= 0 {
  124. s.deleteConfirmation = -1
  125. s.updateListItems()
  126. return s, nil
  127. }
  128. }
  129. }
  130. var cmd tea.Cmd
  131. listModel, cmd := s.list.Update(msg)
  132. s.list = listModel.(list.List[sessionItem])
  133. return s, cmd
  134. }
  135. func (s *sessionDialog) Render(background string) string {
  136. listView := s.list.View()
  137. t := theme.CurrentTheme()
  138. helpStyle := styles.BaseStyle().PaddingLeft(1).PaddingTop(1)
  139. helpText := styles.BaseStyle().Foreground(t.Text()).Render("x/del")
  140. helpText = helpText + styles.BaseStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Render(" delete session")
  141. helpText = helpStyle.Render(helpText)
  142. content := strings.Join([]string{listView, helpText}, "\n")
  143. return s.modal.Render(content, background)
  144. }
  145. func (s *sessionDialog) updateListItems() {
  146. _, currentIdx := s.list.GetSelectedItem()
  147. var items []sessionItem
  148. for i, sess := range s.sessions {
  149. item := sessionItem{
  150. title: sess.Title,
  151. isDeleteConfirming: s.deleteConfirmation == i,
  152. }
  153. items = append(items, item)
  154. }
  155. s.list.SetItems(items)
  156. s.list.SetSelectedIndex(currentIdx)
  157. }
  158. func (s *sessionDialog) deleteSession(sessionID string) tea.Cmd {
  159. return func() tea.Msg {
  160. ctx := context.Background()
  161. if err := s.app.DeleteSession(ctx, sessionID); err != nil {
  162. return toast.NewErrorToast("Failed to delete session: " + err.Error())()
  163. }
  164. return nil
  165. }
  166. }
  167. func (s *sessionDialog) Close() tea.Cmd {
  168. return nil
  169. }
  170. // NewSessionDialog creates a new session switching dialog
  171. func NewSessionDialog(app *app.App) SessionDialog {
  172. sessions, _ := app.ListSessions(context.Background())
  173. var filteredSessions []client.SessionInfo
  174. var items []sessionItem
  175. for _, sess := range sessions {
  176. if sess.ParentID != nil {
  177. continue
  178. }
  179. filteredSessions = append(filteredSessions, sess)
  180. items = append(items, sessionItem{
  181. title: sess.Title,
  182. isDeleteConfirming: false,
  183. })
  184. }
  185. // Create a generic list component
  186. listComponent := list.NewListComponent(
  187. items,
  188. 10, // maxVisibleSessions
  189. "No sessions available",
  190. true, // useAlphaNumericKeys
  191. )
  192. listComponent.SetMaxWidth(layout.Current.Container.Width - 12)
  193. return &sessionDialog{
  194. sessions: filteredSessions,
  195. list: listComponent,
  196. app: app,
  197. deleteConfirmation: -1,
  198. modal: modal.New(
  199. modal.WithTitle("Switch Session"),
  200. modal.WithMaxWidth(layout.Current.Container.Width-8),
  201. ),
  202. }
  203. }