// This file implements the /rewind command (edit checkpoint / rewind): pigo's // analogue of Claude Code's Esc-Esc rewind. Where /tree only moves the // conversation leaf, /rewind also restores the working tree — it replays the // file-snapshot journal (see agenttool.FileSnapshotRecorder) so a turn's write // and edit mutations are rolled back, then switches the active conversation leaf // to the point before that turn. The two together return the session to an // earlier state in code and dialogue at once. // // Scope (v1): only pigo's own write/edit tools are journaled. Files changed by // bash commands are not captured and are left untouched by a rewind. package repl import ( "fmt" "io" "path/filepath" "strconv" "strings" "time" "github.com/smallnest/pigo/internal/agentcore" "github.com/smallnest/pigo/internal/agenttool" "github.com/smallnest/pigo/internal/cli" "github.com/smallnest/pigo/internal/session" ) // rewindLabel derives a short one-line description of a turn from its prompt, for // display in the /rewind list. It collapses whitespace and truncates so the list // stays scannable. func rewindLabel(prompt string) string { label := strings.Join(strings.Fields(prompt), " ") const max = 60 if len(label) > max { label = label[:max-1] + "…" } return label } // runRewind handles the /rewind command. With no argument it persists the live // turn and prints the numbered restore points (most useful last). With "/rewind // N" it restores files to their state before the N-th listed point and switches // the conversation to the leaf that preceded that turn. func runRewind(out io.Writer, deps *replDeps, line string) { if deps.snap == nil { fmt.Fprintln(out, "rewind is unavailable (file tools are disabled)") return } // Persist any un-saved turn first so the just-run turn's restore point exists // and the leaf ids we switch to are on disk. cli.PersistTurn(out, deps) points := deps.snap.Points() fields := strings.Fields(line) if len(fields) < 2 { printRewindPoints(out, points) return } if len(points) == 0 { fmt.Fprintln(out, "no restore points yet — file edits create them") return } n, err := strconv.Atoi(fields[1]) if err != nil || n < 1 || n > len(points) { fmt.Fprintf(out, "invalid selection %q — run /rewind to list points (1..%d)\n", fields[1], len(points)) return } leafID, restored, warnings, rErr := deps.snap.Restore(n - 1) if rErr != nil { fmt.Fprintf(out, "pigo: rewind failed: %v\n", rErr) return } if len(restored) > 0 { fmt.Fprintf(out, "restored %d file(s):\n", len(restored)) for _, p := range restored { fmt.Fprintf(out, " %s\n", displayPath(deps.cwd, p)) } } else { fmt.Fprintln(out, "no files to restore for this point") } for _, w := range warnings { fmt.Fprintf(out, " warning: %s\n", w) } // Move the conversation back to the leaf that preceded the turn, rebuilding the // shared context from that leaf's root→leaf path (same mechanism as /tree). An // empty leaf id means the turn was the first in the session: reset to an empty // conversation. if !rewindConversation(out, deps, leafID) { return } fmt.Fprintf(out, "rewound to before point %d — next prompt continues from here\n", n) } // rewindConversation switches the active leaf to leafID and rebuilds the shared // context from its path. A "" leafID resets to an empty conversation (the turn // was the session's first). It reports whether the switch succeeded. func rewindConversation(out io.Writer, deps *replDeps, leafID string) bool { if leafID == "" { deps.agentCtx.Messages = nil deps.curLeaf = "" deps.persisted = 0 return true } _, entries, err := deps.store.LoadEntries(deps.header.ID) if err != nil { fmt.Fprintf(out, "pigo: cannot read session tree: %v\n", err) return false } path := session.PathToLeaf(entries, leafID) if len(path) == 0 { fmt.Fprintf(out, "pigo: restore point's conversation node is no longer in the tree; files were restored but the conversation was left unchanged\n") return false } msgs := make(agentcore.MessageList, len(path)) for i, e := range path { msgs[i] = e.Message } deps.agentCtx.Messages = msgs deps.curLeaf = leafID deps.persisted = len(msgs) return true } // printRewindPoints renders the numbered restore points, oldest first, showing // when each was made, how many files it touched, and the turn's label. func printRewindPoints(out io.Writer, points []agenttool.RestorePoint) { if len(points) == 0 { fmt.Fprintln(out, "no restore points yet — file edits create them") return } fmt.Fprintln(out, "restore points (run /rewind to roll files + conversation back to before that point):") for i, p := range points { files := len(p.Snapshots) unit := "files" if files == 1 { unit = "file" } when := p.Time.Local().Format(time.Kitchen) label := p.Label if label == "" { label = "(no prompt)" } fmt.Fprintf(out, " %d. %s %d %s %s\n", i+1, when, files, unit, label) } } // displayPath shortens an absolute snapshot path to a workspace-relative form for // display when it lives under cwd; otherwise it returns the absolute path. func displayPath(cwd, abs string) string { if cwd == "" { return abs } if rel, err := filepath.Rel(cwd, abs); err == nil && !strings.HasPrefix(rel, "..") { return rel } return abs }