session.go 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  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, isFirstInViewport bool) 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. func (s sessionItem) Selectable() bool {
  68. return true
  69. }
  70. type sessionDialog struct {
  71. width int
  72. height int
  73. modal *modal.Modal
  74. sessions []opencode.Session
  75. list list.List[sessionItem]
  76. app *app.App
  77. deleteConfirmation int // -1 means no confirmation, >= 0 means confirming deletion of session at this index
  78. }
  79. func (s *sessionDialog) Init() tea.Cmd {
  80. return nil
  81. }
  82. func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  83. switch msg := msg.(type) {
  84. case tea.WindowSizeMsg:
  85. s.width = msg.Width
  86. s.height = msg.Height
  87. s.list.SetMaxWidth(layout.Current.Container.Width - 12)
  88. case tea.KeyPressMsg:
  89. switch msg.String() {
  90. case "enter":
  91. if s.deleteConfirmation >= 0 {
  92. s.deleteConfirmation = -1
  93. s.updateListItems()
  94. return s, nil
  95. }
  96. if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
  97. selectedSession := s.sessions[idx]
  98. return s, tea.Sequence(
  99. util.CmdHandler(modal.CloseModalMsg{}),
  100. util.CmdHandler(app.SessionSelectedMsg(&selectedSession)),
  101. )
  102. }
  103. case "n":
  104. s.app.Session = &opencode.Session{}
  105. s.app.Messages = []app.Message{}
  106. return s, tea.Sequence(
  107. util.CmdHandler(modal.CloseModalMsg{}),
  108. util.CmdHandler(app.SessionClearedMsg{}),
  109. )
  110. case "x", "delete", "backspace":
  111. if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
  112. if s.deleteConfirmation == idx {
  113. // Second press - actually delete the session
  114. sessionToDelete := s.sessions[idx]
  115. return s, tea.Sequence(
  116. func() tea.Msg {
  117. s.sessions = slices.Delete(s.sessions, idx, idx+1)
  118. s.deleteConfirmation = -1
  119. s.updateListItems()
  120. return nil
  121. },
  122. s.deleteSession(sessionToDelete.ID),
  123. )
  124. } else {
  125. // First press - enter delete confirmation mode
  126. s.deleteConfirmation = idx
  127. s.updateListItems()
  128. return s, nil
  129. }
  130. }
  131. case "esc":
  132. if s.deleteConfirmation >= 0 {
  133. s.deleteConfirmation = -1
  134. s.updateListItems()
  135. return s, nil
  136. }
  137. }
  138. }
  139. var cmd tea.Cmd
  140. listModel, cmd := s.list.Update(msg)
  141. s.list = listModel.(list.List[sessionItem])
  142. return s, cmd
  143. }
  144. func (s *sessionDialog) Render(background string) string {
  145. listView := s.list.View()
  146. t := theme.CurrentTheme()
  147. keyStyle := styles.NewStyle().Foreground(t.Text()).Background(t.BackgroundPanel()).Render
  148. mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
  149. leftHelp := keyStyle("n") + mutedStyle(" new session")
  150. rightHelp := keyStyle("x/del") + mutedStyle(" delete session")
  151. bgColor := t.BackgroundPanel()
  152. helpText := layout.Render(layout.FlexOptions{
  153. Direction: layout.Row,
  154. Justify: layout.JustifySpaceBetween,
  155. Width: layout.Current.Container.Width - 14,
  156. Background: &bgColor,
  157. }, layout.FlexItem{View: leftHelp}, layout.FlexItem{View: rightHelp})
  158. helpText = styles.NewStyle().PaddingLeft(1).PaddingTop(1).Render(helpText)
  159. content := strings.Join([]string{listView, helpText}, "\n")
  160. return s.modal.Render(content, background)
  161. }
  162. func (s *sessionDialog) updateListItems() {
  163. _, currentIdx := s.list.GetSelectedItem()
  164. var items []sessionItem
  165. for i, sess := range s.sessions {
  166. item := sessionItem{
  167. title: sess.Title,
  168. isDeleteConfirming: s.deleteConfirmation == i,
  169. }
  170. items = append(items, item)
  171. }
  172. s.list.SetItems(items)
  173. s.list.SetSelectedIndex(currentIdx)
  174. }
  175. func (s *sessionDialog) deleteSession(sessionID string) tea.Cmd {
  176. return func() tea.Msg {
  177. ctx := context.Background()
  178. if err := s.app.DeleteSession(ctx, sessionID); err != nil {
  179. return toast.NewErrorToast("Failed to delete session: " + err.Error())()
  180. }
  181. return nil
  182. }
  183. }
  184. func (s *sessionDialog) Close() tea.Cmd {
  185. return nil
  186. }
  187. // NewSessionDialog creates a new session switching dialog
  188. func NewSessionDialog(app *app.App) SessionDialog {
  189. sessions, _ := app.ListSessions(context.Background())
  190. var filteredSessions []opencode.Session
  191. var items []sessionItem
  192. for _, sess := range sessions {
  193. if sess.ParentID != "" {
  194. continue
  195. }
  196. filteredSessions = append(filteredSessions, sess)
  197. items = append(items, sessionItem{
  198. title: sess.Title,
  199. isDeleteConfirming: false,
  200. })
  201. }
  202. // Create a generic list component
  203. listComponent := list.NewListComponent(
  204. items,
  205. 10, // maxVisibleSessions
  206. "No sessions available",
  207. true, // useAlphaNumericKeys
  208. )
  209. listComponent.SetMaxWidth(layout.Current.Container.Width - 12)
  210. return &sessionDialog{
  211. sessions: filteredSessions,
  212. list: listComponent,
  213. app: app,
  214. deleteConfirmation: -1,
  215. modal: modal.New(
  216. modal.WithTitle("Switch Session"),
  217. modal.WithMaxWidth(layout.Current.Container.Width-8),
  218. ),
  219. }
  220. }