session.go 6.8 KB

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