opencode/packages/tui/internal/components/dialog/session.go
2025-08-15 13:50:54 -05:00

400 lines
11 KiB
Go

package dialog
import (
"context"
"strings"
"slices"
"github.com/charmbracelet/bubbles/v2/textinput"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/muesli/reflow/truncate"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/components/toast"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
// SessionDialog interface for the session switching dialog
type SessionDialog interface {
layout.Modal
}
// sessionItem is a custom list item for sessions that can show delete confirmation
type sessionItem struct {
title string
isDeleteConfirming bool
isCurrentSession bool
}
func (s sessionItem) Render(
selected bool,
width int,
isFirstInViewport bool,
baseStyle styles.Style,
) string {
t := theme.CurrentTheme()
var text string
if s.isDeleteConfirming {
text = "Press again to confirm delete"
} else {
if s.isCurrentSession {
text = "● " + s.title
} else {
text = s.title
}
}
truncatedStr := truncate.StringWithTail(text, uint(width-1), "...")
var itemStyle styles.Style
if selected {
if s.isDeleteConfirming {
// Red background for delete confirmation
itemStyle = baseStyle.
Background(t.Error()).
Foreground(t.BackgroundElement()).
Width(width).
PaddingLeft(1)
} else if s.isCurrentSession {
// Different style for current session when selected
itemStyle = baseStyle.
Background(t.Primary()).
Foreground(t.BackgroundElement()).
Width(width).
PaddingLeft(1).
Bold(true)
} else {
// Normal selection
itemStyle = baseStyle.
Background(t.Primary()).
Foreground(t.BackgroundElement()).
Width(width).
PaddingLeft(1)
}
} else {
if s.isDeleteConfirming {
// Red text for delete confirmation when not selected
itemStyle = baseStyle.
Foreground(t.Error()).
PaddingLeft(1)
} else if s.isCurrentSession {
// Highlight current session when not selected
itemStyle = baseStyle.
Foreground(t.Primary()).
PaddingLeft(1).
Bold(true)
} else {
itemStyle = baseStyle.
PaddingLeft(1)
}
}
return itemStyle.Render(truncatedStr)
}
func (s sessionItem) Selectable() bool {
return true
}
type sessionDialog struct {
width int
height int
modal *modal.Modal
sessions []opencode.Session
list list.List[sessionItem]
app *app.App
deleteConfirmation int // -1 means no confirmation, >= 0 means confirming deletion of session at this index
renameMode bool
renameInput textinput.Model
renameIndex int // index of session being renamed
}
func (s *sessionDialog) Init() tea.Cmd {
return nil
}
func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
s.width = msg.Width
s.height = msg.Height
s.list.SetMaxWidth(layout.Current.Container.Width - 12)
case tea.KeyPressMsg:
if s.renameMode {
switch msg.String() {
case "enter":
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) && idx == s.renameIndex {
newTitle := s.renameInput.Value()
if strings.TrimSpace(newTitle) != "" {
sessionToUpdate := s.sessions[idx]
return s, tea.Sequence(
func() tea.Msg {
ctx := context.Background()
err := s.app.UpdateSession(ctx, sessionToUpdate.ID, newTitle)
if err != nil {
return toast.NewErrorToast("Failed to rename session: " + err.Error())()
}
s.sessions[idx].Title = newTitle
s.renameMode = false
s.modal.SetTitle("Switch Session")
s.updateListItems()
return toast.NewSuccessToast("Session renamed successfully")()
},
)
}
}
s.renameMode = false
s.modal.SetTitle("Switch Session")
s.updateListItems()
return s, nil
default:
var cmd tea.Cmd
s.renameInput, cmd = s.renameInput.Update(msg)
return s, cmd
}
} else {
switch msg.String() {
case "enter":
if s.deleteConfirmation >= 0 {
s.deleteConfirmation = -1
s.updateListItems()
return s, nil
}
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
selectedSession := s.sessions[idx]
return s, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(app.SessionSelectedMsg(&selectedSession)),
)
}
case "n":
return s, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(app.SessionClearedMsg{}),
)
case "r":
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
s.renameMode = true
s.renameIndex = idx
s.setupRenameInput(s.sessions[idx].Title)
s.modal.SetTitle("Rename Session")
s.updateListItems()
return s, textinput.Blink
}
case "x", "delete", "backspace":
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
if s.deleteConfirmation == idx {
// Second press - actually delete the session
sessionToDelete := s.sessions[idx]
return s, tea.Sequence(
func() tea.Msg {
s.sessions = slices.Delete(s.sessions, idx, idx+1)
s.deleteConfirmation = -1
s.updateListItems()
return nil
},
s.deleteSession(sessionToDelete.ID),
)
} else {
// First press - enter delete confirmation mode
s.deleteConfirmation = idx
s.updateListItems()
return s, nil
}
}
case "esc":
if s.deleteConfirmation >= 0 {
s.deleteConfirmation = -1
s.updateListItems()
return s, nil
}
}
}
}
if !s.renameMode {
var cmd tea.Cmd
listModel, cmd := s.list.Update(msg)
s.list = listModel.(list.List[sessionItem])
return s, cmd
}
return s, nil
}
func (s *sessionDialog) Render(background string) string {
if s.renameMode {
// Show rename input instead of list
t := theme.CurrentTheme()
renameView := s.renameInput.View()
mutedStyle := styles.NewStyle().
Foreground(t.TextMuted()).
Background(t.BackgroundPanel()).
Render
helpText := mutedStyle("Enter to confirm, Esc to cancel")
helpText = styles.NewStyle().PaddingLeft(1).PaddingTop(1).Render(helpText)
content := strings.Join([]string{renameView, helpText}, "\n")
return s.modal.Render(content, background)
}
listView := s.list.View()
t := theme.CurrentTheme()
keyStyle := styles.NewStyle().
Foreground(t.Text()).
Background(t.BackgroundPanel()).
Bold(true).
Render
mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
leftHelp := keyStyle("n") + mutedStyle(" new ") + keyStyle("r") + mutedStyle(" rename")
rightHelp := keyStyle("x/del") + mutedStyle(" delete")
bgColor := t.BackgroundPanel()
helpText := layout.Render(layout.FlexOptions{
Direction: layout.Row,
Justify: layout.JustifySpaceBetween,
Width: layout.Current.Container.Width - 14,
Background: &bgColor,
}, layout.FlexItem{View: leftHelp}, layout.FlexItem{View: rightHelp})
helpText = styles.NewStyle().PaddingLeft(1).PaddingTop(1).Render(helpText)
content := strings.Join([]string{listView, helpText}, "\n")
return s.modal.Render(content, background)
}
func (s *sessionDialog) setupRenameInput(currentTitle string) {
t := theme.CurrentTheme()
bgColor := t.BackgroundPanel()
textColor := t.Text()
textMutedColor := t.TextMuted()
s.renameInput = textinput.New()
s.renameInput.SetValue(currentTitle)
s.renameInput.Focus()
s.renameInput.CharLimit = 100
s.renameInput.SetWidth(layout.Current.Container.Width - 20)
s.renameInput.Styles.Blurred.Placeholder = styles.NewStyle().
Foreground(textMutedColor).
Background(bgColor).
Lipgloss()
s.renameInput.Styles.Blurred.Text = styles.NewStyle().
Foreground(textColor).
Background(bgColor).
Lipgloss()
s.renameInput.Styles.Focused.Placeholder = styles.NewStyle().
Foreground(textMutedColor).
Background(bgColor).
Lipgloss()
s.renameInput.Styles.Focused.Text = styles.NewStyle().
Foreground(textColor).
Background(bgColor).
Lipgloss()
s.renameInput.Styles.Focused.Prompt = styles.NewStyle().
Background(bgColor).
Lipgloss()
}
func (s *sessionDialog) updateListItems() {
_, currentIdx := s.list.GetSelectedItem()
var items []sessionItem
for i, sess := range s.sessions {
item := sessionItem{
title: sess.Title,
isDeleteConfirming: s.deleteConfirmation == i,
isCurrentSession: s.app.Session != nil && s.app.Session.ID == sess.ID,
}
items = append(items, item)
}
s.list.SetItems(items)
s.list.SetSelectedIndex(currentIdx)
}
func (s *sessionDialog) deleteSession(sessionID string) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
if err := s.app.DeleteSession(ctx, sessionID); err != nil {
return toast.NewErrorToast("Failed to delete session: " + err.Error())()
}
return nil
}
}
// ReopenSessionModalMsg is emitted when the session modal should be reopened
type ReopenSessionModalMsg struct{}
func (s *sessionDialog) Close() tea.Cmd {
if s.renameMode {
// If in rename mode, exit rename mode and return a command to reopen the modal
s.renameMode = false
s.modal.SetTitle("Switch Session")
s.updateListItems()
// Return a command that will reopen the session modal
return func() tea.Msg {
return ReopenSessionModalMsg{}
}
}
// Normal close behavior
return nil
}
// NewSessionDialog creates a new session switching dialog
func NewSessionDialog(app *app.App) SessionDialog {
sessions, _ := app.ListSessions(context.Background())
var filteredSessions []opencode.Session
var items []sessionItem
for _, sess := range sessions {
if sess.ParentID != "" {
continue
}
filteredSessions = append(filteredSessions, sess)
items = append(items, sessionItem{
title: sess.Title,
isDeleteConfirming: false,
isCurrentSession: app.Session != nil && app.Session.ID == sess.ID,
})
}
listComponent := list.NewListComponent(
list.WithItems(items),
list.WithMaxVisibleHeight[sessionItem](10),
list.WithFallbackMessage[sessionItem]("No sessions available"),
list.WithAlphaNumericKeys[sessionItem](true),
list.WithRenderFunc(
func(item sessionItem, selected bool, width int, baseStyle styles.Style) string {
return item.Render(selected, width, false, baseStyle)
},
),
list.WithSelectableFunc(func(item sessionItem) bool {
return true
}),
)
listComponent.SetMaxWidth(layout.Current.Container.Width - 12)
return &sessionDialog{
sessions: filteredSessions,
list: listComponent,
app: app,
deleteConfirmation: -1,
renameMode: false,
renameIndex: -1,
modal: modal.New(
modal.WithTitle("Switch Session"),
modal.WithMaxWidth(layout.Current.Container.Width-8),
),
}
}