diff --git a/docs/features/tui/index.md b/docs/features/tui/index.md index a5d758308..ad6a6b36f 100644 --- a/docs/features/tui/index.md +++ b/docs/features/tui/index.md @@ -40,7 +40,8 @@ Type `/` during a session to see available commands, or press Ctrl+Ctrl+↑/ (or j/k) to highlight an entry, then press r to reset the workspace to that point. Pick `` to revert every snapshot and bring the workspace back to its pre-agent state. Esc closes the dialog without changing anything. + +Neither command removes messages from the session transcript — they only touch files on disk. Both commands (and the matching command-palette entries) are hidden when snapshots are turned off. Omit `snapshot` or set it to `false` to leave automatic snapshots off; agents can still configure snapshot hooks manually. ## File Attachments diff --git a/pkg/app/app_test.go b/pkg/app/app_test.go index 93f79d6e4..a1ded0fd9 100644 --- a/pkg/app/app_test.go +++ b/pkg/app/app_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/docker/docker-agent/pkg/hooks/builtins" "github.com/docker/docker-agent/pkg/runtime" "github.com/docker/docker-agent/pkg/session" "github.com/docker/docker-agent/pkg/sessiontitle" @@ -80,6 +81,11 @@ func (m *mockRuntime) FollowUp(_ runtime.QueuedMessage) error { return nil } func (m *mockRuntime) UndoLastSnapshot(context.Context, *session.Session) (int, bool, error) { return m.undoFiles, m.undoOK, m.undoErr } +func (m *mockRuntime) SnapshotsEnabled() bool { return true } +func (m *mockRuntime) ListSnapshots(*session.Session) []builtins.SnapshotInfo { return nil } +func (m *mockRuntime) ResetSnapshot(context.Context, *session.Session, int) (int, bool, error) { + return m.undoFiles, m.undoOK, m.undoErr +} // Verify mockRuntime implements runtime.Runtime var _ runtime.Runtime = (*mockRuntime)(nil) @@ -254,6 +260,15 @@ func TestApp_UndoLastSnapshot_NoSnapshot(t *testing.T) { assert.ErrorIs(t, err, ErrNothingToUndo) } +func TestApp_SnapshotsEnabled_DoesNotRequireSession(t *testing.T) { + t.Parallel() + + // SnapshotsEnabled answers a runtime-capability question; it must not + // silently return false just because no session is attached. + app := &App{runtime: &mockRuntime{}, session: nil} + assert.True(t, app.SnapshotsEnabled()) +} + func TestApp_RegenerateSessionTitle(t *testing.T) { t.Parallel() diff --git a/pkg/app/undo.go b/pkg/app/undo.go index cb650e7d0..8c8b86cd4 100644 --- a/pkg/app/undo.go +++ b/pkg/app/undo.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" + "github.com/docker/docker-agent/pkg/hooks/builtins" "github.com/docker/docker-agent/pkg/session" ) @@ -14,19 +15,72 @@ type UndoSnapshotResult struct { RestoredFiles int } -type snapshotUndoer interface { +// snapshotRuntime is the subset of the runtime API that the App needs to +// drive snapshot commands. Runtimes that don't capture snapshots (e.g. +// remote runtimes) simply don't implement this interface and the related +// commands are then disabled in the UI. +type snapshotRuntime interface { + SnapshotsEnabled() bool UndoLastSnapshot(ctx context.Context, sess *session.Session) (files int, ok bool, err error) + ListSnapshots(sess *session.Session) []builtins.SnapshotInfo + ResetSnapshot(ctx context.Context, sess *session.Session, keep int) (files int, ok bool, err error) } +// snapshotRuntime returns the runtime's snapshot interface, or nil when the +// runtime doesn't support snapshots at all (e.g. remote runtimes). +func (a *App) snapshotRuntime() snapshotRuntime { + r, _ := a.runtime.(snapshotRuntime) + return r +} + +// SnapshotsEnabled reports whether automatic shadow-git snapshots are active +// for the current runtime. The answer doesn't depend on having an active +// session: it's a runtime/configuration capability check. +func (a *App) SnapshotsEnabled() bool { + r := a.snapshotRuntime() + return r != nil && r.SnapshotsEnabled() +} + +// UndoLastSnapshot restores the files captured in the most recent snapshot. func (a *App) UndoLastSnapshot(ctx context.Context) (UndoSnapshotResult, error) { - if a.session == nil { + r := a.snapshotRuntime() + if r == nil || a.session == nil { return UndoSnapshotResult{}, ErrNothingToUndo } - undoer, ok := a.runtime.(snapshotUndoer) - if !ok { + return snapshotResult(r.UndoLastSnapshot(ctx, a.session)) +} + +// ListSnapshots returns the file count of every snapshot captured during the +// current session, oldest first. Returns nil when no snapshots exist or when +// the runtime doesn't support them. +func (a *App) ListSnapshots() []int { + r := a.snapshotRuntime() + if r == nil || a.session == nil { + return nil + } + infos := r.ListSnapshots(a.session) + counts := make([]int, len(infos)) + for i, info := range infos { + counts[i] = info.Files + } + return counts +} + +// ResetSnapshot reverts every checkpoint past index keep so the workspace +// returns to the state captured at that snapshot. keep == 0 resets to the +// original pre-agent state. +func (a *App) ResetSnapshot(ctx context.Context, keep int) (UndoSnapshotResult, error) { + r := a.snapshotRuntime() + if r == nil || a.session == nil { return UndoSnapshotResult{}, ErrNothingToUndo } - files, ok, err := undoer.UndoLastSnapshot(ctx, a.session) + return snapshotResult(r.ResetSnapshot(ctx, a.session, keep)) +} + +// snapshotResult adapts the (files, ok, err) tuple returned by snapshot +// operations into the UndoSnapshotResult / ErrNothingToUndo shape callers +// expect. +func snapshotResult(files int, ok bool, err error) (UndoSnapshotResult, error) { if err != nil { return UndoSnapshotResult{}, fmt.Errorf("restoring snapshot: %w", err) } diff --git a/pkg/hooks/builtins/builtins.go b/pkg/hooks/builtins/builtins.go index 694ecb856..8df4204fe 100644 --- a/pkg/hooks/builtins/builtins.go +++ b/pkg/hooks/builtins/builtins.go @@ -80,6 +80,25 @@ func (s *State) UndoLastSnapshot(ctx context.Context, sessionID, cwd string) (fi return s.snapshot.undoLast(ctx, sessionID, cwd) } +// ListSnapshots returns the completed snapshot checkpoints for a session in +// chronological order (oldest first). Returns nil when no snapshots exist. +func (s *State) ListSnapshots(sessionID string) []SnapshotInfo { + if s == nil || s.snapshot == nil || sessionID == "" { + return nil + } + return s.snapshot.listSnapshots(sessionID) +} + +// ResetSnapshot reverts every checkpoint past index keep so the workspace +// returns to the state captured at that snapshot. keep == 0 resets to the +// original (pre-agent) state. +func (s *State) ResetSnapshot(ctx context.Context, sessionID, cwd string, keep int) (files int, ok bool, err error) { + if s == nil || s.snapshot == nil || sessionID == "" || cwd == "" { + return 0, false, nil + } + return s.snapshot.resetSnapshot(ctx, sessionID, cwd, keep) +} + // Register installs the stock builtin hooks on r and returns a [State] // handle the caller can use for stateful builtin operations. func Register(r *hooks.Registry) (*State, error) { diff --git a/pkg/hooks/builtins/snapshot.go b/pkg/hooks/builtins/snapshot.go index c9424a9ba..fb437cbab 100644 --- a/pkg/hooks/builtins/snapshot.go +++ b/pkg/hooks/builtins/snapshot.go @@ -13,6 +13,12 @@ import ( // Snapshot is the registered name of the snapshot builtin. const Snapshot = "snapshot" +// SnapshotInfo summarises one completed snapshot checkpoint for display. +type SnapshotInfo struct { + // Files is the number of unique files captured in the checkpoint. + Files int +} + type snapshotBuiltin struct { manager *snapshot.Manager mu sync.Mutex @@ -204,6 +210,72 @@ func (b *snapshotBuiltin) undoLast(ctx context.Context, sessionID, cwd string) ( return len(checkpoint.files), true, nil } +// listSnapshots returns the completed checkpoints for a session in chronological +// order (oldest first). The returned slice may be empty. +func (b *snapshotBuiltin) listSnapshots(sessionID string) []SnapshotInfo { + b.mu.Lock() + defer b.mu.Unlock() + s := b.session[sessionID] + if s == nil { + return nil + } + out := make([]SnapshotInfo, len(s.history)) + for i, c := range s.history { + out[i] = SnapshotInfo{Files: len(c.files)} + } + return out +} + +// resetSnapshot reverts every checkpoint with index >= keep so the workspace +// returns to the state captured at snapshot keep. keep == 0 means "reset to +// the original state". A keep value greater than or equal to the snapshot +// count is a no-op. Reverted checkpoints are dropped from the session history. +func (b *snapshotBuiltin) resetSnapshot(ctx context.Context, sessionID, cwd string, keep int) (files int, ok bool, err error) { + tail := b.popHistoryTail(sessionID, keep) + if len(tail) == 0 { + return 0, false, nil + } + repo, err := b.manager.Open(ctx, cwd) + if err != nil { + return 0, true, err + } + patches := make([]snapshot.Patch, len(tail)) + seen := map[string]bool{} + for i, c := range tail { + patches[i] = snapshot.Patch{Hash: c.hash, Files: c.files} + for _, f := range c.files { + seen[f] = true + } + } + if err := repo.Revert(ctx, patches); err != nil { + return 0, true, err + } + return len(seen), true, nil +} + +// popHistoryTail removes and returns checkpoints with index >= keep, leaving +// the surviving prefix in the session history. keep is clamped to [0, len]. +// The popped slots in the backing array are zeroed so the dropped file lists +// can be garbage-collected before the slice grows past them again. +func (b *snapshotBuiltin) popHistoryTail(sessionID string, keep int) []snapshotCheckpoint { + b.mu.Lock() + defer b.mu.Unlock() + s := b.session[sessionID] + if s == nil { + return nil + } + if keep < 0 { + keep = 0 + } + if keep >= len(s.history) { + return nil + } + tail := append([]snapshotCheckpoint(nil), s.history[keep:]...) + clear(s.history[keep:]) + s.history = s.history[:keep] + return tail +} + func logPatch(ctx context.Context, scope, sessionID, label string, patch snapshot.Patch, after string) { if len(patch.Files) == 0 { return diff --git a/pkg/hooks/builtins/snapshot_test.go b/pkg/hooks/builtins/snapshot_test.go index 240acdc7d..d748e1172 100644 --- a/pkg/hooks/builtins/snapshot_test.go +++ b/pkg/hooks/builtins/snapshot_test.go @@ -82,6 +82,122 @@ func TestSnapshotBuiltinUndoSurvivesStreamEnd(t *testing.T) { assert.NoFileExists(t, changedPath) } +func TestSnapshotBuiltinListAndReset(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + paths.SetDataDir(t.TempDir()) + t.Cleanup(func() { paths.SetDataDir("") }) + + r := hooks.NewRegistry() + state, err := builtins.Register(r) + require.NoError(t, err) + fn, ok := r.LookupBuiltin(builtins.Snapshot) + require.True(t, ok) + + dir := snapshotBuiltinRepo(t) + + // Initially: no checkpoints. + assert.Empty(t, state.ListSnapshots("s")) + + // Capture three snapshots: each turn modifies one file. + recordTurn := func(t *testing.T, name, contents string) { + t.Helper() + _, err := fn(t.Context(), &hooks.Input{ + SessionID: "s", + Cwd: dir, + HookEventName: hooks.EventTurnStart, + }, nil) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(contents), 0o644)) + _, err = fn(t.Context(), &hooks.Input{ + SessionID: "s", + Cwd: dir, + HookEventName: hooks.EventTurnEnd, + Reason: "continue", + }, nil) + require.NoError(t, err) + } + + recordTurn(t, "a.txt", "a") + recordTurn(t, "b.txt", "b") + recordTurn(t, "c.txt", "c") + + snaps := state.ListSnapshots("s") + require.Len(t, snaps, 3) + assert.Equal(t, 1, snaps[0].Files) + assert.Equal(t, 1, snaps[1].Files) + assert.Equal(t, 1, snaps[2].Files) + + // Reset to snapshot 2: revert turn 3 only, leaving a.txt and b.txt intact. + files, restored, err := state.ResetSnapshot(t.Context(), "s", dir, 2) + require.NoError(t, err) + assert.True(t, restored) + assert.Equal(t, 1, files) + assert.FileExists(t, filepath.Join(dir, "a.txt")) + assert.FileExists(t, filepath.Join(dir, "b.txt")) + assert.NoFileExists(t, filepath.Join(dir, "c.txt")) + require.Len(t, state.ListSnapshots("s"), 2) + + // Reset to original: revert remaining checkpoints, deleting all three files. + files, restored, err = state.ResetSnapshot(t.Context(), "s", dir, 0) + require.NoError(t, err) + assert.True(t, restored) + assert.Equal(t, 2, files) + assert.NoFileExists(t, filepath.Join(dir, "a.txt")) + assert.NoFileExists(t, filepath.Join(dir, "b.txt")) + assert.Empty(t, state.ListSnapshots("s")) + + // Subsequent reset is a no-op (nothing to revert). + _, restored, err = state.ResetSnapshot(t.Context(), "s", dir, 0) + require.NoError(t, err) + assert.False(t, restored) +} + +func TestSnapshotBuiltinResetKeepBeyondHistoryIsNoop(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + paths.SetDataDir(t.TempDir()) + t.Cleanup(func() { paths.SetDataDir("") }) + + r := hooks.NewRegistry() + state, err := builtins.Register(r) + require.NoError(t, err) + fn, ok := r.LookupBuiltin(builtins.Snapshot) + require.True(t, ok) + + dir := snapshotBuiltinRepo(t) + _, err = fn(t.Context(), &hooks.Input{ + SessionID: "s", + Cwd: dir, + HookEventName: hooks.EventTurnStart, + }, nil) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a"), 0o644)) + _, err = fn(t.Context(), &hooks.Input{ + SessionID: "s", + Cwd: dir, + HookEventName: hooks.EventTurnEnd, + Reason: "continue", + }, nil) + require.NoError(t, err) + + // keep == len(history) means "keep everything" — no checkpoints reverted. + files, restored, err := state.ResetSnapshot(t.Context(), "s", dir, 1) + require.NoError(t, err) + assert.False(t, restored) + assert.Equal(t, 0, files) + assert.FileExists(t, filepath.Join(dir, "a.txt")) + require.Len(t, state.ListSnapshots("s"), 1) + + // keep way past the end is also a no-op. + _, restored, err = state.ResetSnapshot(t.Context(), "s", dir, 99) + require.NoError(t, err) + assert.False(t, restored) + require.Len(t, state.ListSnapshots("s"), 1) +} + func snapshotBuiltinRepo(t *testing.T) string { t.Helper() dir := t.TempDir() diff --git a/pkg/runtime/snapshot.go b/pkg/runtime/snapshot.go index aa552566c..b80beafce 100644 --- a/pkg/runtime/snapshot.go +++ b/pkg/runtime/snapshot.go @@ -4,6 +4,7 @@ import ( "context" "os" + "github.com/docker/docker-agent/pkg/hooks/builtins" "github.com/docker/docker-agent/pkg/session" ) @@ -14,17 +15,54 @@ func WithSnapshots(enabled bool) Opt { } } +// SnapshotsEnabled reports whether automatic snapshot hooks are active for +// this runtime. Used by the TUI to hide snapshot-related commands when the +// feature is off. +func (r *LocalRuntime) SnapshotsEnabled() bool { + return r != nil && r.snapshotsEnabled +} + // UndoLastSnapshot restores files recorded for the latest completed snapshot hook checkpoint. -func (r *LocalRuntime) UndoLastSnapshot(ctx context.Context, sess *session.Session) (files int, ok bool, err error) { - if r == nil || sess == nil { +func (r *LocalRuntime) UndoLastSnapshot(ctx context.Context, sess *session.Session) (int, bool, error) { + cwd := r.snapshotCwd(sess) + if cwd == "" { return 0, false, nil } - cwd := sess.WorkingDir - if cwd == "" { - cwd = r.workingDir + return r.builtinsState.UndoLastSnapshot(ctx, sess.ID, cwd) +} + +// ListSnapshots returns the completed snapshot checkpoints recorded for the +// session, oldest first. Returns nil when none exist. +func (r *LocalRuntime) ListSnapshots(sess *session.Session) []builtins.SnapshotInfo { + if r == nil || sess == nil { + return nil } + return r.builtinsState.ListSnapshots(sess.ID) +} + +// ResetSnapshot reverts every checkpoint past index keep so the workspace +// returns to the state captured at that snapshot. keep == 0 resets to the +// original (pre-agent) state. +func (r *LocalRuntime) ResetSnapshot(ctx context.Context, sess *session.Session, keep int) (int, bool, error) { + cwd := r.snapshotCwd(sess) if cwd == "" { - cwd, _ = os.Getwd() + return 0, false, nil } - return r.builtinsState.UndoLastSnapshot(ctx, sess.ID, cwd) + return r.builtinsState.ResetSnapshot(ctx, sess.ID, cwd, keep) +} + +// snapshotCwd resolves the working directory used to open the shadow +// repository for snapshot operations. Returns "" when no candidate is usable. +func (r *LocalRuntime) snapshotCwd(sess *session.Session) string { + if r == nil || sess == nil { + return "" + } + if sess.WorkingDir != "" { + return sess.WorkingDir + } + if r.workingDir != "" { + return r.workingDir + } + cwd, _ := os.Getwd() + return cwd } diff --git a/pkg/tui/commands/commands.go b/pkg/tui/commands/commands.go index 006e04c16..25002009f 100644 --- a/pkg/tui/commands/commands.go +++ b/pkg/tui/commands/commands.go @@ -106,6 +106,17 @@ func builtInSessionCommands() []Item { return core.CmdHandler(messages.UndoSnapshotMsg{}) }, }, + { + ID: "session.snapshots", + Label: "Snapshots", + SlashCommand: "/snapshots", + Description: "List captured snapshots", + Category: "Session", + Immediate: true, + Execute: func(string) tea.Cmd { + return core.CmdHandler(messages.ShowSnapshotsDialogMsg{}) + }, + }, { ID: "session.cost", Label: "Cost", @@ -392,10 +403,32 @@ func sortByLabel(items []Item) []Item { return items } +// snapshotCommandIDs is the set of IDs that depend on the snapshot feature. +// They are stripped from the palette and the slash-command parser when +// snapshots are turned off. +var snapshotCommandIDs = map[string]bool{ + "session.undo": true, + "session.snapshots": true, +} + +// removeByIDs returns items whose IDs are not in ids. +func removeByIDs(items []Item, ids map[string]bool) []Item { + out := make([]Item, 0, len(items)) + for _, item := range items { + if !ids[item.ID] { + out = append(out, item) + } + } + return out +} + // BuildCommandCategories builds the list of command categories for the command palette func BuildCommandCategories(ctx context.Context, application *app.App) []Category { // Get session commands and filter based on model capabilities sessionCommands := builtInSessionCommands() + if !application.SnapshotsEnabled() { + sessionCommands = removeByIDs(sessionCommands, snapshotCommandIDs) + } categories := []Category{ { diff --git a/pkg/tui/commands/commands_test.go b/pkg/tui/commands/commands_test.go index b44928854..74c751751 100644 --- a/pkg/tui/commands/commands_test.go +++ b/pkg/tui/commands/commands_test.go @@ -107,6 +107,15 @@ func TestParseSlashCommand_OtherCommands(t *testing.T) { assert.True(t, ok) }) + t.Run("snapshots command", func(t *testing.T) { + t.Parallel() + cmd := parser.Parse("/snapshots") + require.NotNil(t, cmd) + msg := cmd() + _, ok := msg.(messages.ShowSnapshotsDialogMsg) + assert.True(t, ok) + }) + t.Run("unknown command returns nil", func(t *testing.T) { t.Parallel() cmd := parser.Parse("/unknown") @@ -150,3 +159,36 @@ func TestParseSlashCommand_Compact(t *testing.T) { assert.Equal(t, "focus on the API design", compactMsg.AdditionalPrompt) }) } + +func TestRemoveByIDsDropsSnapshotCommands(t *testing.T) { + t.Parallel() + + items := builtInSessionCommands() + require.NotEmpty(t, items) + + hasID := func(items []Item, id string) bool { + for _, it := range items { + if it.ID == id { + return true + } + } + return false + } + + require.True(t, hasID(items, "session.undo")) + require.True(t, hasID(items, "session.snapshots")) + + filtered := removeByIDs(items, snapshotCommandIDs) + assert.False(t, hasID(filtered, "session.undo")) + assert.False(t, hasID(filtered, "session.snapshots")) + // Other commands are untouched. + assert.True(t, hasID(filtered, "session.exit")) + assert.True(t, hasID(filtered, "session.new")) + + // Build a parser that mirrors the disabled-snapshots state and verify + // that the snapshot slash commands no longer resolve. + parser := NewParser(Category{Name: "Session", Commands: filtered}) + assert.Nil(t, parser.Parse("/undo")) + assert.Nil(t, parser.Parse("/snapshots")) + require.NotNil(t, parser.Parse("/exit")) +} diff --git a/pkg/tui/dialog/snapshot.go b/pkg/tui/dialog/snapshot.go new file mode 100644 index 000000000..01c26f61c --- /dev/null +++ b/pkg/tui/dialog/snapshot.go @@ -0,0 +1,163 @@ +package dialog + +import ( + "fmt" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/docker/docker-agent/pkg/tui/core" + "github.com/docker/docker-agent/pkg/tui/core/layout" + "github.com/docker/docker-agent/pkg/tui/messages" + "github.com/docker/docker-agent/pkg/tui/styles" +) + +const ( + snapshotsDialogWidthPercent = 60 + snapshotsDialogMinWidth = 40 + snapshotsDialogMaxWidth = 70 +) + +// snapshotsDialog lists every captured snapshot and lets the user reset the +// workspace to one of them (or to the original pre-agent state). +type snapshotsDialog struct { + BaseDialog + + // fileCounts holds the number of files captured in each snapshot, oldest + // first. An empty slice puts the dialog in its empty state. + fileCounts []int + // selected is the highlighted entry. 0 = , N = snapshot N. + selected int +} + +// NewSnapshotsDialog creates a snapshots dialog. fileCounts must be in +// chronological order (oldest first). +func NewSnapshotsDialog(fileCounts []int) Dialog { + return &snapshotsDialog{fileCounts: fileCounts} +} + +func (d *snapshotsDialog) Init() tea.Cmd { return nil } + +func (d *snapshotsDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + cmd := d.SetSize(msg.Width, msg.Height) + return d, cmd + + case tea.KeyPressMsg: + if cmd := HandleQuit(msg); cmd != nil { + return d, cmd + } + cmd := d.handleKey(msg) + return d, cmd + } + return d, nil +} + +func (d *snapshotsDialog) handleKey(msg tea.KeyPressMsg) tea.Cmd { + switch msg.String() { + case "esc", "q": + return core.CmdHandler(CloseDialogMsg{}) + case "up", "k": + if d.selected > 0 { + d.selected-- + } + case "down", "j": + if d.selected < len(d.fileCounts) { + d.selected++ + } + case "home", "g": + d.selected = 0 + case "end", "G": + d.selected = len(d.fileCounts) + case "r": + if len(d.fileCounts) == 0 { + return nil + } + return tea.Sequence( + core.CmdHandler(CloseDialogMsg{}), + core.CmdHandler(messages.ResetSnapshotMsg{Keep: d.selected}), + ) + } + return nil +} + +func (d *snapshotsDialog) Position() (row, col int) { + return d.CenterDialog(d.View()) +} + +func (d *snapshotsDialog) View() string { + width := d.ComputeDialogWidth(snapshotsDialogWidthPercent, snapshotsDialogMinWidth, snapshotsDialogMaxWidth) + inner := d.ContentWidth(width, 2) + + content := NewContent(inner).AddTitle("Snapshots").AddSeparator().AddSpace() + + if len(d.fileCounts) > 0 { + count := pluralize(len(d.fileCounts), "snapshot", "snapshots") + " captured" + content = content. + AddContent(styles.DialogOptionsStyle.Width(inner).Render(count)). + AddSpace() + } + + body := content. + AddContent(d.bodyContent(inner)). + AddSpace(). + AddHelpKeys(d.helpKeys()...). + Build() + + return styles.DialogStyle.Width(width).Render(body) +} + +// bodyContent returns either the empty-state line or the snapshot list, +// depending on whether any snapshots were captured. +func (d *snapshotsDialog) bodyContent(inner int) string { + if len(d.fileCounts) == 0 { + return styles.DialogContentStyle. + Italic(true). + Foreground(styles.TextMuted). + Width(inner). + Align(lipgloss.Center). + Render("No snapshots taken yet.") + } + + rows := make([]string, 0, len(d.fileCounts)+1) + rows = append(rows, d.renderRow("", "restore the initial state", d.selected == 0, inner)) + for i, count := range d.fileCounts { + rows = append(rows, d.renderRow( + fmt.Sprintf("Snapshot %d", i+1), + pluralize(count, "file", "files"), + d.selected == i+1, + inner, + )) + } + return lipgloss.JoinVertical(lipgloss.Left, rows...) +} + +func (d *snapshotsDialog) helpKeys() []string { + if len(d.fileCounts) == 0 { + return []string{"esc", "close"} + } + return []string{"↑/↓", "navigate", "r", "restore", "esc", "close"} +} + +// renderRow draws a single list entry with the name on the left and a short +// description right-aligned within width. +func (d *snapshotsDialog) renderRow(name, desc string, selected bool, width int) string { + nameStyle, descStyle := styles.PaletteUnselectedActionStyle, styles.PaletteUnselectedDescStyle + if selected { + nameStyle, descStyle = styles.PaletteSelectedActionStyle, styles.PaletteSelectedDescStyle + } + left := nameStyle.Render(" " + name + " ") + right := descStyle.Render(" " + desc + " ") + gap := max(0, width-lipgloss.Width(left)) + return left + lipgloss.PlaceHorizontal(gap, lipgloss.Right, right, + lipgloss.WithWhitespaceStyle(descStyle)) +} + +func pluralize(n int, singular, plural string) string { + word := plural + if n == 1 { + word = singular + } + return fmt.Sprintf("%d %s", n, word) +} diff --git a/pkg/tui/handlers.go b/pkg/tui/handlers.go index a3cc10ea9..1f5047ddc 100644 --- a/pkg/tui/handlers.go +++ b/pkg/tui/handlers.go @@ -263,6 +263,33 @@ func (m *appModel) handleUndoSnapshot() (tea.Model, tea.Cmd) { return m, notification.SuccessCmd(text) } +func (m *appModel) handleShowSnapshotsDialog() (tea.Model, tea.Cmd) { + snapshots := m.application.ListSnapshots() + return m, core.CmdHandler(dialog.OpenDialogMsg{ + Model: dialog.NewSnapshotsDialog(snapshots), + }) +} + +func (m *appModel) handleResetSnapshot(keep int) (tea.Model, tea.Cmd) { + if m.chatPage.IsWorking() { + return m, notification.WarningCmd("Wait for the current response to finish before resetting") + } + result, err := m.application.ResetSnapshot(context.Background(), keep) + if err != nil { + if errors.Is(err, app.ErrNothingToUndo) { + return m, notification.InfoCmd("Nothing to reset") + } + return m, notification.ErrorCmd(fmt.Sprintf("Failed to reset snapshot: %v", err)) + } + + target := "the original state" + if keep > 0 { + target = fmt.Sprintf("snapshot %d", keep) + } + text := fmt.Sprintf("Restored %d file%s to %s", result.RestoredFiles, plural(result.RestoredFiles), target) + return m, notification.SuccessCmd(text) +} + func plural(n int) string { if n == 1 { return "" diff --git a/pkg/tui/messages/session.go b/pkg/tui/messages/session.go index 430798001..616bf58fb 100644 --- a/pkg/tui/messages/session.go +++ b/pkg/tui/messages/session.go @@ -50,6 +50,15 @@ type ( // UndoSnapshotMsg restores files from the latest snapshot. UndoSnapshotMsg struct{} + // ShowSnapshotsDialogMsg requests opening the snapshots dialog. + ShowSnapshotsDialogMsg struct{} + + // ResetSnapshotMsg requests restoring the workspace to a snapshot. + // Keep is the number of snapshots to retain in chronological order: + // 0 reverts every snapshot (back to the original pre-agent state), + // N keeps snapshots 1..N and reverts any later ones. + ResetSnapshotMsg struct{ Keep int } + // ExportSessionMsg exports the session to the specified file. ExportSessionMsg struct{ Filename string } diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index ba62c0be7..08691c88f 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -823,6 +823,12 @@ func (m *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case messages.UndoSnapshotMsg: return m.handleUndoSnapshot() + case messages.ShowSnapshotsDialogMsg: + return m.handleShowSnapshotsDialog() + + case messages.ResetSnapshotMsg: + return m.handleResetSnapshot(msg.Keep) + case messages.EvalSessionMsg: return m.handleEvalSession(msg.Filename)