Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
29 changes: 25 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 Down Expand Up @@ -78,6 +80,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 +128,14 @@ 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
SessionDeletePrompt bool
SessionDeleting bool
SessionDeleteID string
SessionDeleteProject string
Comment thread
egdev6 marked this conversation as resolved.
Outdated

// Clipboard feedback
CopyFeedback string // "βœ“ Copied!" or "" β€” shown for 2 s after copy
Expand Down Expand Up @@ -228,6 +239,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
67 changes: 67 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,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.Sessions = msg.sessions
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 +114,14 @@ 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.
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 +460,27 @@ func (m Model) handleTimelineKeys(key string) (tea.Model, tea.Cmd) {
// ─── Sessions ────────────────────────────────────────────────────────────────

func (m Model) handleSessionsKeys(key string) (tea.Model, tea.Cmd) {
if m.SessionDeleting {
return m, nil
}
if m.SessionDeletePrompt {
switch key {
case "y", "Y":
if m.SessionDeleteID == "" {
m = m.resetSessionDeleteState()
return m, nil
}
sessionID := m.SessionDeleteID
m.SessionDeletePrompt = false
m.SessionDeleting = true
return m, deleteSession(m.store, sessionID)
case "n", "N", "esc":
m = m.resetSessionDeleteState()
return m, nil
Comment thread
egdev6 marked this conversation as resolved.
}
return m, nil
}

visibleItems := m.Height - 8
if visibleItems < 5 {
visibleItems = 5
Expand Down Expand Up @@ -467,10 +508,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":
if len(m.Sessions) > 0 && m.Cursor < len(m.Sessions) {
session := m.Sessions[m.Cursor]
m.SessionDeletePrompt = true
m.SessionDeleteID = session.ID
m.SessionDeleteProject = session.Project
}
Comment thread
egdev6 marked this conversation as resolved.
Outdated
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 +643,24 @@ func (m Model) handleSetupKeys(key string) (tea.Model, tea.Cmd) {

// ─── Helpers ─────────────────────────────────────────────────────────────────

func (m Model) resetSessionDeleteState() Model {
m.SessionDeletePrompt = false
m.SessionDeleting = false
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
139 changes: 139 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,144 @@ 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.SessionDeletePrompt || updated.SessionDeleteID != fx.otherSession || updated.SessionDeleteProject != "engram" {
t.Fatalf("delete prompt state = prompt:%v id:%q project:%q", updated.SessionDeletePrompt, 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.SessionDeletePrompt || updated.SessionDeleting || 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.SessionDeletePrompt || updated.SessionDeleting || 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.SessionDeletePrompt || !updated.SessionDeleting {
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)
}
updatedModel, refreshCmd := updated.Update(msg)
updated = updatedModel.(Model)
if updated.SessionDeletePrompt || updated.SessionDeleting || updated.SessionDeleteID != "" {
t.Fatal("delete result should clear prompt state")
}
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.SessionDeletePrompt || updated.SessionDeleting {
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.SessionDeletePrompt {
t.Fatal("delete with no sessions should not open prompt")
}
})
}

func TestRefreshScreen(t *testing.T) {
m := New(newTestFixture(t).store, "")

Expand Down
23 changes: 22 additions & 1 deletion internal/tui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,27 @@ func (m Model) viewSessions() string {
b.WriteString(headerStyle.Render(header))
b.WriteString("\n")

if m.SessionDeleting {
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()
}

if m.SessionDeletePrompt {
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")
Expand Down Expand Up @@ -498,7 +519,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()
}
Expand Down
27 changes: 27 additions & 0 deletions internal/tui/view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,33 @@ 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.SessionDeletePrompt = true
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.SessionDeletePrompt = false
m.SessionDeleting = true
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{}
Expand Down
Loading