session.go 7.4 KB

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