session.go 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. package dialog
  2. import (
  3. "github.com/charmbracelet/bubbles/key"
  4. tea "github.com/charmbracelet/bubbletea"
  5. "github.com/charmbracelet/lipgloss"
  6. "github.com/opencode-ai/opencode/internal/session"
  7. "github.com/opencode-ai/opencode/internal/tui/layout"
  8. "github.com/opencode-ai/opencode/internal/tui/styles"
  9. "github.com/opencode-ai/opencode/internal/tui/theme"
  10. "github.com/opencode-ai/opencode/internal/tui/util"
  11. )
  12. // SessionSelectedMsg is sent when a session is selected
  13. type SessionSelectedMsg struct {
  14. Session session.Session
  15. }
  16. // CloseSessionDialogMsg is sent when the session dialog is closed
  17. type CloseSessionDialogMsg struct{}
  18. // SessionDialog interface for the session switching dialog
  19. type SessionDialog interface {
  20. tea.Model
  21. layout.Bindings
  22. SetSessions(sessions []session.Session)
  23. SetSelectedSession(sessionID string)
  24. }
  25. type sessionDialogCmp struct {
  26. sessions []session.Session
  27. selectedIdx int
  28. width int
  29. height int
  30. selectedSessionID string
  31. }
  32. type sessionKeyMap struct {
  33. Up key.Binding
  34. Down key.Binding
  35. Enter key.Binding
  36. Escape key.Binding
  37. J key.Binding
  38. K key.Binding
  39. }
  40. var sessionKeys = sessionKeyMap{
  41. Up: key.NewBinding(
  42. key.WithKeys("up"),
  43. key.WithHelp("↑", "previous session"),
  44. ),
  45. Down: key.NewBinding(
  46. key.WithKeys("down"),
  47. key.WithHelp("↓", "next session"),
  48. ),
  49. Enter: key.NewBinding(
  50. key.WithKeys("enter"),
  51. key.WithHelp("enter", "select session"),
  52. ),
  53. Escape: key.NewBinding(
  54. key.WithKeys("esc"),
  55. key.WithHelp("esc", "close"),
  56. ),
  57. J: key.NewBinding(
  58. key.WithKeys("j"),
  59. key.WithHelp("j", "next session"),
  60. ),
  61. K: key.NewBinding(
  62. key.WithKeys("k"),
  63. key.WithHelp("k", "previous session"),
  64. ),
  65. }
  66. func (s *sessionDialogCmp) Init() tea.Cmd {
  67. return nil
  68. }
  69. func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  70. switch msg := msg.(type) {
  71. case tea.KeyMsg:
  72. switch {
  73. case key.Matches(msg, sessionKeys.Up) || key.Matches(msg, sessionKeys.K):
  74. if s.selectedIdx > 0 {
  75. s.selectedIdx--
  76. }
  77. return s, nil
  78. case key.Matches(msg, sessionKeys.Down) || key.Matches(msg, sessionKeys.J):
  79. if s.selectedIdx < len(s.sessions)-1 {
  80. s.selectedIdx++
  81. }
  82. return s, nil
  83. case key.Matches(msg, sessionKeys.Enter):
  84. if len(s.sessions) > 0 {
  85. return s, util.CmdHandler(SessionSelectedMsg{
  86. Session: s.sessions[s.selectedIdx],
  87. })
  88. }
  89. case key.Matches(msg, sessionKeys.Escape):
  90. return s, util.CmdHandler(CloseSessionDialogMsg{})
  91. }
  92. case tea.WindowSizeMsg:
  93. s.width = msg.Width
  94. s.height = msg.Height
  95. }
  96. return s, nil
  97. }
  98. func (s *sessionDialogCmp) View() string {
  99. t := theme.CurrentTheme()
  100. baseStyle := styles.BaseStyle()
  101. if len(s.sessions) == 0 {
  102. return baseStyle.Padding(1, 2).
  103. Border(lipgloss.RoundedBorder()).
  104. BorderBackground(t.Background()).
  105. BorderForeground(t.TextMuted()).
  106. Width(40).
  107. Render("No sessions available")
  108. }
  109. // Calculate max width needed for session titles
  110. maxWidth := 40 // Minimum width
  111. for _, sess := range s.sessions {
  112. if len(sess.Title) > maxWidth-4 { // Account for padding
  113. maxWidth = len(sess.Title) + 4
  114. }
  115. }
  116. maxWidth = max(30, min(maxWidth, s.width-15)) // Limit width to avoid overflow
  117. // Limit height to avoid taking up too much screen space
  118. maxVisibleSessions := min(10, len(s.sessions))
  119. // Build the session list
  120. sessionItems := make([]string, 0, maxVisibleSessions)
  121. startIdx := 0
  122. // If we have more sessions than can be displayed, adjust the start index
  123. if len(s.sessions) > maxVisibleSessions {
  124. // Center the selected item when possible
  125. halfVisible := maxVisibleSessions / 2
  126. if s.selectedIdx >= halfVisible && s.selectedIdx < len(s.sessions)-halfVisible {
  127. startIdx = s.selectedIdx - halfVisible
  128. } else if s.selectedIdx >= len(s.sessions)-halfVisible {
  129. startIdx = len(s.sessions) - maxVisibleSessions
  130. }
  131. }
  132. endIdx := min(startIdx+maxVisibleSessions, len(s.sessions))
  133. for i := startIdx; i < endIdx; i++ {
  134. sess := s.sessions[i]
  135. itemStyle := baseStyle.Width(maxWidth)
  136. if i == s.selectedIdx {
  137. itemStyle = itemStyle.
  138. Background(t.Primary()).
  139. Foreground(t.Background()).
  140. Bold(true)
  141. }
  142. sessionItems = append(sessionItems, itemStyle.Padding(0, 1).Render(sess.Title))
  143. }
  144. title := baseStyle.
  145. Foreground(t.Primary()).
  146. Bold(true).
  147. Width(maxWidth).
  148. Padding(0, 1).
  149. Render("Switch Session")
  150. content := lipgloss.JoinVertical(
  151. lipgloss.Left,
  152. title,
  153. baseStyle.Width(maxWidth).Render(""),
  154. baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, sessionItems...)),
  155. baseStyle.Width(maxWidth).Render(""),
  156. )
  157. return baseStyle.Padding(1, 2).
  158. Border(lipgloss.RoundedBorder()).
  159. BorderBackground(t.Background()).
  160. BorderForeground(t.TextMuted()).
  161. Width(lipgloss.Width(content) + 4).
  162. Render(content)
  163. }
  164. func (s *sessionDialogCmp) BindingKeys() []key.Binding {
  165. return layout.KeyMapToSlice(sessionKeys)
  166. }
  167. func (s *sessionDialogCmp) SetSessions(sessions []session.Session) {
  168. s.sessions = sessions
  169. // If we have a selected session ID, find its index
  170. if s.selectedSessionID != "" {
  171. for i, sess := range sessions {
  172. if sess.ID == s.selectedSessionID {
  173. s.selectedIdx = i
  174. return
  175. }
  176. }
  177. }
  178. // Default to first session if selected not found
  179. s.selectedIdx = 0
  180. }
  181. func (s *sessionDialogCmp) SetSelectedSession(sessionID string) {
  182. s.selectedSessionID = sessionID
  183. // Update the selected index if sessions are already loaded
  184. if len(s.sessions) > 0 {
  185. for i, sess := range s.sessions {
  186. if sess.ID == sessionID {
  187. s.selectedIdx = i
  188. return
  189. }
  190. }
  191. }
  192. }
  193. // NewSessionDialogCmp creates a new session switching dialog
  194. func NewSessionDialogCmp() SessionDialog {
  195. return &sessionDialogCmp{
  196. sessions: []session.Session{},
  197. selectedIdx: 0,
  198. selectedSessionID: "",
  199. }
  200. }