first commit
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
// 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 <n> 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
|
||||
}
|
||||
Reference in New Issue
Block a user