diff --git a/internal/tui/model.go b/internal/tui/model.go index 144240d5..536d97d7 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -10,6 +10,8 @@ package tui import ( + "errors" + "github.com/Gentleman-Programming/engram/internal/setup" "github.com/Gentleman-Programming/engram/internal/store" "github.com/Gentleman-Programming/engram/internal/version" @@ -36,6 +38,14 @@ const ( ScreenSetup ) +type SessionDeleteState int + +const ( + SessionDeleteStateNone SessionDeleteState = iota + SessionDeleteStatePrompt + SessionDeleteStateDeleting +) + // ─── Custom Messages ───────────────────────────────────────────────────────── type updateCheckMsg struct { @@ -78,6 +88,11 @@ type sessionObservationsMsg struct { err error } +type sessionDeletedMsg struct { + sessionID string + err error +} + type setupInstallMsg struct { result *setup.Result err error @@ -121,10 +136,13 @@ type Model struct { Timeline *store.TimelineResult // Sessions - Sessions []store.SessionSummary - SelectedSessionIdx int - SessionObservations []store.Observation - SessionDetailScroll int + Sessions []store.SessionSummary + SelectedSessionIdx int + SessionObservations []store.Observation + SessionDetailScroll int + SessionDeleteState SessionDeleteState + SessionDeleteID string + SessionDeleteProject string // Clipboard feedback CopyFeedback string // "✓ Copied!" or "" — shown for 2 s after copy @@ -228,6 +246,16 @@ func loadSessionObservations(s *store.Store, sessionID string) tea.Cmd { } } +func deleteSession(s *store.Store, sessionID string) tea.Cmd { + return func() tea.Msg { + if s == nil { + return sessionDeletedMsg{sessionID: sessionID, err: errors.New("store is unavailable")} + } + err := s.DeleteSession(sessionID) + return sessionDeletedMsg{sessionID: sessionID, err: err} + } +} + func installAgent(agentName string) tea.Cmd { return func() tea.Msg { result, err := installAgentFn(agentName) diff --git a/internal/tui/update.go b/internal/tui/update.go index 1c5cfe00..c8f09b11 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -1,9 +1,12 @@ package tui import ( + "errors" + "fmt" "time" "github.com/Gentleman-Programming/engram/internal/setup" + "github.com/Gentleman-Programming/engram/internal/store" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" ) @@ -89,6 +92,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.Sessions = msg.sessions + if m.Screen == ScreenSessions { + if len(m.Sessions) == 0 { + m.Cursor = 0 + m.Scroll = 0 + } else if m.Cursor >= len(m.Sessions) { + m.Cursor = len(m.Sessions) - 1 + if m.Scroll > m.Cursor { + m.Scroll = m.Cursor + } + } + } return m, nil case sessionObservationsMsg: @@ -102,6 +116,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.SessionDetailScroll = 0 return m, nil + case sessionDeletedMsg: + m = m.resetSessionDeleteState() + if msg.err != nil { + m.ErrorMsg = sessionDeleteErrorMessage(msg.sessionID, msg.err) + return m, nil + } + m.ErrorMsg = "" + return m, loadRecentSessions(m.store) + case setupInstallMsg: m.SetupInstalling = false if msg.err != nil { @@ -440,6 +463,26 @@ func (m Model) handleTimelineKeys(key string) (tea.Model, tea.Cmd) { // ─── Sessions ──────────────────────────────────────────────────────────────── func (m Model) handleSessionsKeys(key string) (tea.Model, tea.Cmd) { + switch m.SessionDeleteState { + case SessionDeleteStateDeleting: + return m, nil + case SessionDeleteStatePrompt: + switch key { + case "y", "Y": + if m.SessionDeleteID == "" { + m = m.resetSessionDeleteState() + return m, nil + } + sessionID := m.SessionDeleteID + m.SessionDeleteState = SessionDeleteStateDeleting + return m, deleteSession(m.store, sessionID) + case "n", "N", "esc": + m = m.resetSessionDeleteState() + return m, nil + } + return m, nil + } + visibleItems := m.Height - 8 if visibleItems < 5 { visibleItems = 5 @@ -467,10 +510,18 @@ func (m Model) handleSessionsKeys(key string) (tea.Model, tea.Cmd) { sessionID := m.Sessions[m.Cursor].ID return m, loadSessionObservations(m.store, sessionID) } + case "d", "D": + if len(m.Sessions) > 0 && m.Cursor < len(m.Sessions) { + session := m.Sessions[m.Cursor] + m.SessionDeleteState = SessionDeleteStatePrompt + m.SessionDeleteID = session.ID + m.SessionDeleteProject = session.Project + } case "esc", "q": m.Screen = ScreenDashboard m.Cursor = 0 m.Scroll = 0 + m = m.resetSessionDeleteState() return m, loadStats(m.store) } return m, nil @@ -594,6 +645,23 @@ func (m Model) handleSetupKeys(key string) (tea.Model, tea.Cmd) { // ─── Helpers ───────────────────────────────────────────────────────────────── +func (m Model) resetSessionDeleteState() Model { + m.SessionDeleteState = SessionDeleteStateNone + m.SessionDeleteID = "" + m.SessionDeleteProject = "" + return m +} + +func sessionDeleteErrorMessage(sessionID string, err error) string { + if errors.Is(err, store.ErrSessionHasObservations) { + return fmt.Sprintf("Cannot delete session %q: it still has observations. Delete or move observations first.", sessionID) + } + if errors.Is(err, store.ErrSessionNotFound) { + return fmt.Sprintf("Cannot delete session %q: session not found.", sessionID) + } + return fmt.Sprintf("Failed to delete session %q: %v", sessionID, err) +} + // refreshScreen returns the appropriate data-loading Cmd for a given screen. // Used when navigating back so lists show fresh data from the DB. func (m Model) refreshScreen(screen Screen) tea.Cmd { diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 3bb15de7..2e9eaccd 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -2,6 +2,7 @@ package tui import ( "errors" + "strings" "testing" "github.com/Gentleman-Programming/engram/internal/setup" @@ -302,6 +303,148 @@ func TestHandleRecentTimelineSessionsAndDetailKeyPaths(t *testing.T) { } } +func TestSessionDeletePromptFlow(t *testing.T) { + t.Run("opens and cancels prompt", func(t *testing.T) { + fx := newTestFixture(t) + m := New(fx.store, "") + m.Screen = ScreenSessions + m.Sessions = []store.SessionSummary{{ID: fx.sessionID, Project: "engram"}, {ID: fx.otherSession, Project: "engram"}} + m.Cursor = 1 + + updatedModel, cmd := m.handleSessionsKeys("D") + updated := updatedModel.(Model) + if cmd != nil { + t.Fatal("opening delete prompt should not return command") + } + if updated.SessionDeleteState != SessionDeleteStatePrompt || updated.SessionDeleteID != fx.otherSession || updated.SessionDeleteProject != "engram" { + t.Fatalf("delete prompt state = state:%v id:%q project:%q", updated.SessionDeleteState, updated.SessionDeleteID, updated.SessionDeleteProject) + } + + updatedModel, cmd = updated.handleSessionsKeys("esc") + updated = updatedModel.(Model) + if cmd != nil { + t.Fatal("esc cancel should not return command") + } + if updated.SessionDeleteState != SessionDeleteStateNone || updated.SessionDeleteID != "" || updated.SessionDeleteProject != "" { + t.Fatal("esc cancel should clear delete prompt state") + } + + updatedModel, _ = m.handleSessionsKeys("d") + updated = updatedModel.(Model) + updatedModel, cmd = updated.handleSessionsKeys("n") + updated = updatedModel.(Model) + if cmd != nil { + t.Fatal("n cancel should not return command") + } + if updated.SessionDeleteState != SessionDeleteStateNone || updated.SessionDeleteID != "" || updated.SessionDeleteProject != "" { + t.Fatal("n cancel should clear delete prompt state") + } + }) + + t.Run("confirm deletes empty session and refreshes", func(t *testing.T) { + fx := newTestFixture(t) + m := New(fx.store, "") + m.Screen = ScreenSessions + m.Sessions = []store.SessionSummary{{ID: fx.sessionID, Project: "engram"}, {ID: fx.otherSession, Project: "engram"}} + m.Cursor = 1 + + updatedModel, _ := m.handleSessionsKeys("d") + updated := updatedModel.(Model) + updatedModel, cmd := updated.handleSessionsKeys("y") + updated = updatedModel.(Model) + if cmd == nil { + t.Fatal("confirm should return delete command") + } + if updated.SessionDeleteState != SessionDeleteStateDeleting { + t.Fatal("confirm should close prompt and mark delete in progress") + } + if _, secondCmd := updated.handleSessionsKeys("y"); secondCmd != nil { + t.Fatal("second confirm while deleting should be ignored") + } + + msg := cmd().(sessionDeletedMsg) + if msg.err != nil { + t.Fatalf("delete command error: %v", msg.err) + } + updated.ErrorMsg = "stale delete error" + updatedModel, refreshCmd := updated.Update(msg) + updated = updatedModel.(Model) + if updated.SessionDeleteState != SessionDeleteStateNone || updated.SessionDeleteID != "" { + t.Fatal("delete result should clear prompt state") + } + if updated.ErrorMsg != "" { + t.Fatalf("successful delete should clear stale error, got %q", updated.ErrorMsg) + } + if refreshCmd == nil { + t.Fatal("successful delete should refresh sessions") + } + if err := fx.store.DeleteSession(fx.otherSession); !errors.Is(err, store.ErrSessionNotFound) { + t.Fatalf("session should be deleted, got err %v", err) + } + }) + + t.Run("blocked delete shows store error", func(t *testing.T) { + fx := newTestFixture(t) + m := New(fx.store, "") + m.Screen = ScreenSessions + m.Sessions = []store.SessionSummary{{ID: fx.sessionID, Project: "engram"}} + + updatedModel, _ := m.handleSessionsKeys("d") + updated := updatedModel.(Model) + _, cmd := updated.handleSessionsKeys("y") + if cmd == nil { + t.Fatal("confirm should return delete command") + } + + msg := cmd().(sessionDeletedMsg) + if !errors.Is(msg.err, store.ErrSessionHasObservations) { + t.Fatalf("expected ErrSessionHasObservations, got %v", msg.err) + } + updatedModel, refreshCmd := updated.Update(msg) + updated = updatedModel.(Model) + if refreshCmd != nil { + t.Fatal("failed delete should not refresh sessions") + } + if updated.ErrorMsg == "" || !strings.Contains(updated.ErrorMsg, "Cannot delete session") { + t.Fatalf("failed delete should surface contextual error message, got %q", updated.ErrorMsg) + } + if updated.SessionDeleteState != SessionDeleteStateNone { + t.Fatal("failed delete should close prompt") + } + }) + + t.Run("nil store returns graceful error", func(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenSessions + m.Sessions = []store.SessionSummary{{ID: "session-missing-store", Project: "engram"}} + + updatedModel, _ := m.handleSessionsKeys("d") + updated := updatedModel.(Model) + _, cmd := updated.handleSessionsKeys("y") + if cmd == nil { + t.Fatal("confirm should return delete command") + } + + msg := cmd().(sessionDeletedMsg) + if msg.err == nil { + t.Fatal("nil store delete should return an error message") + } + }) + + t.Run("delete key ignored without sessions", func(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenSessions + updatedModel, cmd := m.handleSessionsKeys("d") + updated := updatedModel.(Model) + if cmd != nil { + t.Fatal("delete with no sessions should not return command") + } + if updated.SessionDeleteState != SessionDeleteStateNone { + t.Fatal("delete with no sessions should not open prompt") + } + }) +} + func TestRefreshScreen(t *testing.T) { m := New(newTestFixture(t).store, "") @@ -406,6 +549,23 @@ func TestUpdateDataMessageBranches(t *testing.T) { t.Fatal("sessions should be updated") } + screenModel := New(nil, "") + screenModel.Screen = ScreenSearch + screenModel.Cursor = 5 + screenModel.Scroll = 4 + updatedModel, _ = screenModel.Update(recentSessionsMsg{sessions: sessions}) + updated = updatedModel.(Model) + if updated.Cursor != 5 || updated.Scroll != 4 { + t.Fatalf("sessions refresh outside sessions screen should not clamp cursor/scroll, got %d/%d", updated.Cursor, updated.Scroll) + } + + screenModel.Screen = ScreenSessions + updatedModel, _ = screenModel.Update(recentSessionsMsg{sessions: sessions}) + updated = updatedModel.(Model) + if updated.Cursor != 0 || updated.Scroll != 0 { + t.Fatalf("sessions refresh on sessions screen should clamp cursor/scroll, got %d/%d", updated.Cursor, updated.Scroll) + } + updatedModel, _ = m.Update(sessionObservationsMsg{err: errors.New("session detail err")}) updated = updatedModel.(Model) if updated.ErrorMsg != "session detail err" { diff --git a/internal/tui/view.go b/internal/tui/view.go index 68a1958b..465332e3 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -451,6 +451,26 @@ func (m Model) viewSessions() string { b.WriteString(headerStyle.Render(header)) b.WriteString("\n") + switch m.SessionDeleteState { + case SessionDeleteStateDeleting: + b.WriteString("\n") + b.WriteString(sectionHeadingStyle.Render(" Deleting Session")) + b.WriteString("\n\n") + b.WriteString(detailContentStyle.Render(fmt.Sprintf(" Deleting session %q...", m.SessionDeleteID))) + b.WriteString("\n") + return b.String() + case SessionDeleteStatePrompt: + b.WriteString("\n") + b.WriteString(sectionHeadingStyle.Render(" Confirm Session Delete")) + b.WriteString("\n\n") + b.WriteString(detailContentStyle.Render(fmt.Sprintf(" Delete session %q from project %q?", m.SessionDeleteID, m.SessionDeleteProject))) + b.WriteString("\n") + b.WriteString(timestampStyle.Render(" Sessions with observations cannot be deleted; Engram will refuse unsafe deletes.")) + b.WriteString("\n\n") + b.WriteString(helpStyle.Render(" [y] Delete [n] Cancel [esc] Cancel")) + return b.String() + } + if count == 0 { b.WriteString(noResultsStyle.Render("No sessions yet.")) b.WriteString("\n\n") @@ -498,7 +518,7 @@ func (m Model) viewSessions() string { timestampStyle.Render(fmt.Sprintf("showing %d-%d of %d", m.Scroll+1, end, count)))) } - b.WriteString(helpStyle.Render("\n j/k navigate • enter view session • esc back")) + b.WriteString(helpStyle.Render("\n j/k navigate • enter view session • d delete • esc back")) return b.String() } diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go index f68aa2eb..8f161289 100644 --- a/internal/tui/view_test.go +++ b/internal/tui/view_test.go @@ -310,6 +310,32 @@ func TestViewObservationDetailTimelineSessionsAndSessionDetail(t *testing.T) { } } +func TestViewSessionsDeletePrompt(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenSessions + m.Sessions = []store.SessionSummary{{ID: "session-1", Project: "engram", StartedAt: "2026-01-01"}} + m.SessionDeleteState = SessionDeleteStatePrompt + m.SessionDeleteID = "session-1" + m.SessionDeleteProject = "engram" + + out := m.viewSessions() + if !strings.Contains(out, "Confirm Session Delete") { + t.Fatal("delete prompt should render heading") + } + if !strings.Contains(out, "session-1") || !strings.Contains(out, "engram") { + t.Fatal("delete prompt should render selected session context") + } + if !strings.Contains(out, "[y] Delete") || !strings.Contains(out, "[n] Cancel") || !strings.Contains(out, "[esc] Cancel") { + t.Fatal("delete prompt should render y/n/esc options") + } + + m.SessionDeleteState = SessionDeleteStateDeleting + out = m.viewSessions() + if !strings.Contains(out, "Deleting Session") || !strings.Contains(out, "session-1") { + t.Fatal("deleting state should render selected session context") + } +} + func TestViewRouterCoversAllScreens(t *testing.T) { m := New(nil, "") m.Stats = &store.Stats{}