session.go 5.8 KB

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