From 4f5d81c257bb6b31e0b86296dc674fe8f785471a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 Aug 2025 14:41:36 +0000 Subject: [PATCH 1/2] Initial plan From c094470738b27498965c3143c80200c763903eac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 Aug 2025 14:50:55 +0000 Subject: [PATCH 2/2] Implement CRUD operations for session dialog Co-authored-by: yyovil <149292478+yyovil@users.noreply.github.com> --- internal/tui/bubbles/dialog/session.go | 389 +++++++++++++++++++++++-- internal/tui/tui.go | 41 +++ 2 files changed, 402 insertions(+), 28 deletions(-) diff --git a/internal/tui/bubbles/dialog/session.go b/internal/tui/bubbles/dialog/session.go index 0381e71..44521c6 100644 --- a/internal/tui/bubbles/dialog/session.go +++ b/internal/tui/bubbles/dialog/session.go @@ -1,7 +1,12 @@ package dialog import ( + "context" + "fmt" + "strings" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/yyovil/tandem/internal/session" @@ -16,6 +21,21 @@ type SessionSelectedMsg struct { Session session.Session } +// SessionCreatedMsg is sent when a session is created +type SessionCreatedMsg struct { + Session session.Session +} + +// SessionUpdatedMsg is sent when a session is updated +type SessionUpdatedMsg struct { + Session session.Session +} + +// SessionDeletedMsg is sent when a session is deleted +type SessionDeletedMsg struct { + SessionID string +} + // CloseSessionDialogMsg is sent when the session dialog is closed type CloseSessionDialogMsg struct{} @@ -25,23 +45,43 @@ type SessionDialog interface { layout.Bindings SetSessions(sessions []session.Session) SetSelectedSession(sessionID string) + SetSessionService(service session.Service) } +type dialogMode int + +const ( + modeList dialogMode = iota + modeEdit + modeCreate + modeConfirmDelete +) + type sessionDialogCmp struct { sessions []session.Session selectedIdx int width int height int selectedSessionID string + sessionService session.Service + + // Input and mode management + mode dialogMode + textInput textinput.Model + originalTitle string } type sessionKeyMap struct { - Up key.Binding - Down key.Binding - Enter key.Binding - Escape key.Binding - J key.Binding - K key.Binding + Up key.Binding + Down key.Binding + Enter key.Binding + Escape key.Binding + J key.Binding + K key.Binding + New key.Binding + Edit key.Binding + Delete key.Binding + Confirm key.Binding } var sessionKeys = sessionKeyMap{ @@ -59,7 +99,7 @@ var sessionKeys = sessionKeyMap{ ), Escape: key.NewBinding( key.WithKeys("esc"), - key.WithHelp("esc", "close"), + key.WithHelp("esc", "close/cancel"), ), J: key.NewBinding( key.WithKeys("j"), @@ -69,43 +109,227 @@ var sessionKeys = sessionKeyMap{ key.WithKeys("k"), key.WithHelp("k", "previous session"), ), + New: key.NewBinding( + key.WithKeys("n"), + key.WithHelp("n", "new session"), + ), + Edit: key.NewBinding( + key.WithKeys("e"), + key.WithHelp("e", "edit session"), + ), + Delete: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "delete session"), + ), + Confirm: key.NewBinding( + key.WithKeys("y"), + key.WithHelp("y", "confirm"), + ), } func (s *sessionDialogCmp) Init() tea.Cmd { + s.setupTextInput() return nil } func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: - switch { - case key.Matches(msg, sessionKeys.Up) || key.Matches(msg, sessionKeys.K): - if s.selectedIdx > 0 { - s.selectedIdx-- - } - return s, nil - case key.Matches(msg, sessionKeys.Down) || key.Matches(msg, sessionKeys.J): - if s.selectedIdx < len(s.sessions)-1 { - s.selectedIdx++ - } - return s, nil - case key.Matches(msg, sessionKeys.Enter): - if len(s.sessions) > 0 { - return s, utils.CmdHandler(SessionSelectedMsg{ - Session: s.sessions[s.selectedIdx], - }) - } - case key.Matches(msg, sessionKeys.Escape): - return s, utils.CmdHandler(CloseSessionDialogMsg{}) + // Handle input modes first + switch s.mode { + case modeEdit, modeCreate: + return s.handleInputMode(msg) + case modeConfirmDelete: + return s.handleDeleteConfirmation(msg) + default: + return s.handleListMode(msg) } case tea.WindowSizeMsg: s.width = msg.Width s.height = msg.Height + s.textInput.Width = max(30, min(s.width-20, 50)) + } + return s, nil +} + +func (s *sessionDialogCmp) handleListMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, sessionKeys.Up) || key.Matches(msg, sessionKeys.K): + if s.selectedIdx > 0 { + s.selectedIdx-- + } + return s, nil + case key.Matches(msg, sessionKeys.Down) || key.Matches(msg, sessionKeys.J): + if s.selectedIdx < len(s.sessions)-1 { + s.selectedIdx++ + } + return s, nil + case key.Matches(msg, sessionKeys.Enter): + if len(s.sessions) > 0 { + return s, utils.CmdHandler(SessionSelectedMsg{ + Session: s.sessions[s.selectedIdx], + }) + } + return s, nil + case key.Matches(msg, sessionKeys.New): + return s.enterCreateMode(), nil + case key.Matches(msg, sessionKeys.Edit): + if len(s.sessions) > 0 { + return s.enterEditMode(), nil + } + return s, nil + case key.Matches(msg, sessionKeys.Delete): + if len(s.sessions) > 0 { + s.mode = modeConfirmDelete + return s, nil + } + return s, nil + case key.Matches(msg, sessionKeys.Escape): + return s, utils.CmdHandler(CloseSessionDialogMsg{}) } return s, nil } +func (s *sessionDialogCmp) handleInputMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, sessionKeys.Enter): + return s.submitInput() + case key.Matches(msg, sessionKeys.Escape): + s.mode = modeList + s.textInput.SetValue("") + return s, nil + default: + var cmd tea.Cmd + s.textInput, cmd = s.textInput.Update(msg) + return s, cmd + } +} + +func (s *sessionDialogCmp) handleDeleteConfirmation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, sessionKeys.Confirm): + return s.deleteSession() + case key.Matches(msg, sessionKeys.Escape): + s.mode = modeList + return s, nil + } + return s, nil +} + +func (s *sessionDialogCmp) enterCreateMode() *sessionDialogCmp { + s.mode = modeCreate + s.textInput.SetValue("") + s.textInput.Focus() + return s +} + +func (s *sessionDialogCmp) enterEditMode() *sessionDialogCmp { + if len(s.sessions) > 0 { + s.mode = modeEdit + selectedSession := s.sessions[s.selectedIdx] + s.originalTitle = selectedSession.Title + s.textInput.SetValue(selectedSession.Title) + s.textInput.Focus() + } + return s +} + +func (s *sessionDialogCmp) submitInput() (tea.Model, tea.Cmd) { + title := strings.TrimSpace(s.textInput.Value()) + if title == "" { + return s, nil + } + + if s.sessionService == nil { + return s, utils.ReportError(fmt.Errorf("session service not available")) + } + + s.textInput.Blur() + + switch s.mode { + case modeCreate: + return s.createSession(title) + case modeEdit: + return s.updateSession(title) + } + + return s, nil +} + +func (s *sessionDialogCmp) createSession(title string) (tea.Model, tea.Cmd) { + s.mode = modeList + s.textInput.SetValue("") + + return s, func() tea.Msg { + ctx := context.Background() + session, err := s.sessionService.Create(ctx, title) + if err != nil { + return utils.ReportError(err) + } + return SessionCreatedMsg{Session: session} + } +} + +func (s *sessionDialogCmp) updateSession(title string) (tea.Model, tea.Cmd) { + s.mode = modeList + s.textInput.SetValue("") + + if len(s.sessions) == 0 { + return s, nil + } + + selectedSession := s.sessions[s.selectedIdx] + selectedSession.Title = title + + return s, func() tea.Msg { + ctx := context.Background() + session, err := s.sessionService.Save(ctx, selectedSession) + if err != nil { + return utils.ReportError(err) + } + return SessionUpdatedMsg{Session: session} + } +} + +func (s *sessionDialogCmp) deleteSession() (tea.Model, tea.Cmd) { + s.mode = modeList + + if len(s.sessions) == 0 { + return s, nil + } + + selectedSession := s.sessions[s.selectedIdx] + + return s, func() tea.Msg { + ctx := context.Background() + err := s.sessionService.Delete(ctx, selectedSession.ID) + if err != nil { + return utils.ReportError(err) + } + return SessionDeletedMsg{SessionID: selectedSession.ID} + } +} + +func (s *sessionDialogCmp) setupTextInput() { + s.textInput = textinput.New() + s.textInput.CharLimit = 100 + s.textInput.Width = 40 +} + func (s *sessionDialogCmp) View() string { + switch s.mode { + case modeCreate: + return s.renderInputMode("Create New Session", "Enter session title:") + case modeEdit: + return s.renderInputMode("Edit Session", "Edit session title:") + case modeConfirmDelete: + return s.renderDeleteConfirmation() + default: + return s.renderListMode() + } +} + +func (s *sessionDialogCmp) renderListMode() string { t := theme.CurrentTheme() baseStyle := styles.BaseStyle() @@ -167,7 +391,14 @@ func (s *sessionDialogCmp) View() string { Bold(true). Width(maxWidth). Padding(0, 1). - Render("Switch Session") + Render("Manage Sessions") + + // Add help text + helpText := baseStyle. + Foreground(t.TextMuted()). + Width(maxWidth). + Padding(0, 1). + Render("n: new e: edit d: delete enter: select esc: close") content := lipgloss.JoinVertical( lipgloss.Left, @@ -175,6 +406,101 @@ func (s *sessionDialogCmp) View() string { baseStyle.Width(maxWidth).Render(""), baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, sessionItems...)), baseStyle.Width(maxWidth).Render(""), + helpText, + ) + + return baseStyle.Padding(1, 2). + Border(lipgloss.NormalBorder()). + BorderBackground(t.Background()). + BorderForeground(t.TextMuted()). + Width(lipgloss.Width(content) + 4). + Render(content) +} + +func (s *sessionDialogCmp) renderInputMode(title, prompt string) string { + t := theme.CurrentTheme() + baseStyle := styles.BaseStyle() + + titleText := baseStyle. + Foreground(t.Primary()). + Bold(true). + Width(50). + Padding(0, 1). + Render(title) + + promptText := baseStyle. + Width(50). + Padding(0, 1). + Render(prompt) + + inputView := s.textInput.View() + + helpText := baseStyle. + Foreground(t.TextMuted()). + Width(50). + Padding(0, 1). + Render("enter: save esc: cancel") + + content := lipgloss.JoinVertical( + lipgloss.Left, + titleText, + baseStyle.Width(50).Render(""), + promptText, + inputView, + baseStyle.Width(50).Render(""), + helpText, + ) + + return baseStyle.Padding(1, 2). + Border(lipgloss.NormalBorder()). + BorderBackground(t.Background()). + BorderForeground(t.TextMuted()). + Width(lipgloss.Width(content) + 4). + Render(content) +} + +func (s *sessionDialogCmp) renderDeleteConfirmation() string { + t := theme.CurrentTheme() + baseStyle := styles.BaseStyle() + + if len(s.sessions) == 0 { + return s.renderListMode() + } + + selectedSession := s.sessions[s.selectedIdx] + + titleText := baseStyle. + Foreground(t.Primary()). + Bold(true). + Width(50). + Padding(0, 1). + Render("Delete Session") + + warningText := baseStyle. + Foreground(lipgloss.Color("#ff6b6b")). + Width(50). + Padding(0, 1). + Render(fmt.Sprintf("Delete session '%s'?", selectedSession.Title)) + + confirmText := baseStyle. + Width(50). + Padding(0, 1). + Render("This action cannot be undone.") + + helpText := baseStyle. + Foreground(t.TextMuted()). + Width(50). + Padding(0, 1). + Render("y: confirm esc: cancel") + + content := lipgloss.JoinVertical( + lipgloss.Left, + titleText, + baseStyle.Width(50).Render(""), + warningText, + confirmText, + baseStyle.Width(50).Render(""), + helpText, ) return baseStyle.Padding(1, 2). @@ -189,6 +515,10 @@ func (s *sessionDialogCmp) BindingKeys() []key.Binding { return utils.KeyMapToSlice(sessionKeys) } +func (s *sessionDialogCmp) SetSessionService(service session.Service) { + s.sessionService = service +} + func (s *sessionDialogCmp) SetSessions(sessions []session.Session) { s.sessions = sessions @@ -222,9 +552,12 @@ func (s *sessionDialogCmp) SetSelectedSession(sessionID string) { // NewSessionDialogCmp creates a new session switching dialog func NewSessionDialogCmp() SessionDialog { - return &sessionDialogCmp{ + s := &sessionDialogCmp{ sessions: []session.Session{}, selectedIdx: 0, selectedSessionID: "", + mode: modeList, } + s.setupTextInput() + return s } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index e12400c..c35b84d 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -122,6 +122,9 @@ func New(app *app.App) tea.Model { filepicker: dialog.NewFilepickerCmp(app), } + // Set the session service for CRUD operations + model.sessionDialog.SetSessionService(app.Sessions) + return model } @@ -304,6 +307,44 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, nil + case dialog.SessionCreatedMsg: + // Refresh the session list and optionally select the new session + sessions, err := a.app.Sessions.List(context.Background()) + if err != nil { + return a, utils.ReportError(err) + } + a.sessionDialog.SetSessions(sessions) + return a, utils.ReportInfo(fmt.Sprintf("Session '%s' created", msg.Session.Title)) + + case dialog.SessionUpdatedMsg: + // Refresh the session list + sessions, err := a.app.Sessions.List(context.Background()) + if err != nil { + return a, utils.ReportError(err) + } + a.sessionDialog.SetSessions(sessions) + // Update the selected session if it's the currently active one + if a.selectedSession.ID == msg.Session.ID { + a.selectedSession = msg.Session + } + return a, utils.ReportInfo(fmt.Sprintf("Session '%s' updated", msg.Session.Title)) + + case dialog.SessionDeletedMsg: + // Refresh the session list + sessions, err := a.app.Sessions.List(context.Background()) + if err != nil { + return a, utils.ReportError(err) + } + a.sessionDialog.SetSessions(sessions) + // Clear the selected session if it was the deleted one + if a.selectedSession.ID == msg.SessionID { + a.selectedSession = session.Session{} + if a.currentPage == page.ChatPage { + return a, utils.CmdHandler(chat.SessionClearedMsg{}) + } + } + return a, utils.ReportInfo("Session deleted") + case tea.KeyMsg: switch {