session.go 6.4 KB

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