Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -36,6 +38,14 @@ const (
ScreenSetup
)

type SessionDeleteState int

const (
SessionDeleteStateNone SessionDeleteState = iota
SessionDeleteStatePrompt
SessionDeleteStateDeleting
)

// ─── Custom Messages ─────────────────────────────────────────────────────────

type updateCheckMsg struct {
Expand Down Expand Up @@ -78,6 +88,11 @@ type sessionObservationsMsg struct {
err error
}

type sessionDeletedMsg struct {
sessionID string
err error
}

type setupInstallMsg struct {
result *setup.Result
err error
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
}
}
Comment thread
egdev6 marked this conversation as resolved.

func installAgent(agentName string) tea.Cmd {
return func() tea.Msg {
result, err := installAgentFn(agentName)
Expand Down
68 changes: 68 additions & 0 deletions internal/tui/update.go
Original file line number Diff line number Diff line change
@@ -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"
)
Expand Down Expand Up @@ -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
}
}
}
Comment thread
egdev6 marked this conversation as resolved.
return m, nil

case sessionObservationsMsg:
Expand All @@ -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
}
Comment thread
egdev6 marked this conversation as resolved.
m.ErrorMsg = ""
return m, loadRecentSessions(m.store)
Comment thread
egdev6 marked this conversation as resolved.

case setupInstallMsg:
m.SetupInstalling = false
if msg.err != nil {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
160 changes: 160 additions & 0 deletions internal/tui/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package tui

import (
"errors"
"strings"
"testing"

"github.com/Gentleman-Programming/engram/internal/setup"
Expand Down Expand Up @@ -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, "")

Expand Down Expand Up @@ -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" {
Expand Down
Loading
Loading