| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400 |
- 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),
- ),
- }
- }
|