Skip to content
Merged
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
12 changes: 9 additions & 3 deletions docs/features/tui/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ Type `/` during a session to see available commands, or press <kbd>Ctrl</kbd>+<k
| `/fork` | Fork the current session into a new branch |
| `/copy` | Copy the entire conversation to clipboard |
| `/copy-last` | Copy only the last assistant message to clipboard |
| `/undo` | Restore file changes from the latest snapshot |
| `/undo` | Restore file changes from the latest snapshot (only when snapshots are enabled) |
| `/snapshots` | List captured snapshots (only when snapshots are enabled) |
| `/export` | Export the session as HTML |
| `/sessions` | Browse and load past sessions |
| `/model` | Change the model for the current agent |
Expand All @@ -60,7 +61,7 @@ Type `/` during a session to see available commands, or press <kbd>Ctrl</kbd>+<k
| `/speak` | Voice input via system speech-to-text (macOS only) |
| `/exit` | Exit the application (aliases: `/quit`, `/q`) |

### Snapshots and `/undo`
### Snapshots, `/undo`, and `/snapshots`

Enable shadow-git snapshots globally in `~/.config/cagent/config.yaml`:

Expand All @@ -69,7 +70,12 @@ settings:
snapshot: true
```

When enabled, docker-agent records filesystem snapshots at turn boundaries. `/undo` restores files from the most recent changed snapshot; it does **not** remove messages from the session transcript. Omit `snapshot` or set it to `false` to leave automatic snapshots off; agents can still configure snapshot hooks manually.
When enabled, docker-agent records filesystem snapshots at turn boundaries. The TUI exposes two slash commands that operate on those snapshots:

- **`/undo`** restores files from the most recent snapshot (one step back).
- **`/snapshots`** opens a dialog showing how many snapshots have been captured and the number of files in each one. Use <kbd>↑</kbd>/<kbd>↓</kbd> (or <kbd>j</kbd>/<kbd>k</kbd>) to highlight an entry, then press <kbd>r</kbd> to reset the workspace to that point. Pick `<original>` to revert every snapshot and bring the workspace back to its pre-agent state. <kbd>Esc</kbd> 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

Expand Down
15 changes: 15 additions & 0 deletions pkg/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()

Expand Down
64 changes: 59 additions & 5 deletions pkg/app/undo.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"

"github.com/docker/docker-agent/pkg/hooks/builtins"
"github.com/docker/docker-agent/pkg/session"
)

Expand All @@ -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)
}
Expand Down
19 changes: 19 additions & 0 deletions pkg/hooks/builtins/builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
72 changes: 72 additions & 0 deletions pkg/hooks/builtins/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
116 changes: 116 additions & 0 deletions pkg/hooks/builtins/snapshot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading