session.go

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