loop: auto-commit changes when saving in diff view

What I've done is create a git commit whenever the user edits things,
and amend if possible. The existing detection logic pushes the commits
to the host, BUT, I had to do some plumbing to make that happen. The
agent state machine would be out of sorts if I did this (since we're
doing things outside of the loop), but, I didn't tell it, so... it's ok!

If the user has the agent running when editing, everyone can get
confused. There's no atomicity for the git operations, etc.
I suspect in practice this will all be as fine as everything else is.

I'm not running the autoformatters. That's a weird editing experience.

The alternative was to do what the diff comments does, and let the agent
deal with the changes by sending it a message. I chose not to do that:
first of all, I want the push to happen fast, since I don't like losing
user data. Second, the latency on an operation is distracting sometimes,
and sometimes what I do next is just cherrypick my changes over, and I'm
not interested in the pedantry of the agent and the code formatters and
so forth. If they were faster, maybe.

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: sec50af415124810bk
diff --git a/git_tools/git_tools.go b/git_tools/git_tools.go
index b206c05..fc59e0d 100644
--- a/git_tools/git_tools.go
+++ b/git_tools/git_tools.go
@@ -3,6 +3,7 @@
 
 import (
 	"bufio"
+	"context"
 	"fmt"
 	"os"
 	"os/exec"
@@ -306,3 +307,41 @@
 
 	return nil
 }
+
+// AutoCommitDiffViewChanges automatically commits changes to the specified file
+// If the last commit message is exactly "User changes from diff view.", it amends the commit
+// Otherwise, it creates a new commit
+func AutoCommitDiffViewChanges(ctx context.Context, repoDir, filePath string) error {
+	// Check if the last commit has the expected message
+	cmd := exec.CommandContext(ctx, "git", "log", "-1", "--pretty=%s")
+	cmd.Dir = repoDir
+	output, err := cmd.Output()
+	commitMsg := strings.TrimSpace(string(output))
+
+	// Check if we should amend or create a new commit
+	const expectedMsg = "User changes from diff view."
+	amend := err == nil && commitMsg == expectedMsg
+
+	// Add the file to git
+	cmd = exec.CommandContext(ctx, "git", "add", filePath)
+	cmd.Dir = repoDir
+	if err := cmd.Run(); err != nil {
+		return fmt.Errorf("error adding file to git: %w", err)
+	}
+
+	// Commit the changes
+	if amend {
+		// Amend the previous commit
+		cmd = exec.CommandContext(ctx, "git", "commit", "--amend", "--no-edit")
+	} else {
+		// Create a new commit
+		cmd = exec.CommandContext(ctx, "git", "commit", "-m", expectedMsg, filePath)
+	}
+	cmd.Dir = repoDir
+
+	if err := cmd.Run(); err != nil {
+		return fmt.Errorf("error committing changes: %w", err)
+	}
+
+	return nil
+}
diff --git a/loop/agent.go b/loop/agent.go
index 98925e2..cb22b84 100644
--- a/loop/agent.go
+++ b/loop/agent.go
@@ -104,6 +104,9 @@
 	// SessionID returns the unique session identifier.
 	SessionID() string
 
+	// DetectGitChanges checks for new git commits and pushes them if found
+	DetectGitChanges(ctx context.Context)
+
 	// OutstandingLLMCallCount returns the number of outstanding LLM calls.
 	OutstandingLLMCallCount() int
 
@@ -1417,7 +1420,18 @@
 	return shouldContinue && !toolEndsTurn, resp
 }
 
-// processGitChanges checks for new git commits and runs autoformatters if needed
+// DetectGitChanges checks for new git commits and pushes them if found
+func (a *Agent) DetectGitChanges(ctx context.Context) {
+	// Check for git commits
+	_, err := a.handleGitCommits(ctx)
+	if err != nil {
+		// Just log the error, don't stop execution
+		slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
+	}
+}
+
+// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
+// This is used internally by the agent loop
 func (a *Agent) processGitChanges(ctx context.Context) []string {
 	// Check for git commits after tool execution
 	newCommits, err := a.handleGitCommits(ctx)
diff --git a/loop/server/loophttp.go b/loop/server/loophttp.go
index aaaf7c2..94cbb78 100644
--- a/loop/server/loophttp.go
+++ b/loop/server/loophttp.go
@@ -1373,6 +1373,16 @@
 		return
 	}
 
+	// Auto-commit the changes
+	err = git_tools.AutoCommitDiffViewChanges(r.Context(), repoDir, requestBody.Path)
+	if err != nil {
+		http.Error(w, fmt.Sprintf("Error auto-committing changes: %v", err), http.StatusInternalServerError)
+		return
+	}
+
+	// Detect git changes to push and notify user
+	s.agent.DetectGitChanges(r.Context())
+
 	// Return simple success response
 	w.WriteHeader(http.StatusOK)
 	w.Write([]byte("ok"))
diff --git a/loop/server/loophttp_test.go b/loop/server/loophttp_test.go
index fc52532..e3d144b 100644
--- a/loop/server/loophttp_test.go
+++ b/loop/server/loophttp_test.go
@@ -242,8 +242,7 @@
 func (m *mockAgent) SuggestReprompt(ctx context.Context) (string, error) { return "", nil }
 func (m *mockAgent) IsInContainer() bool                                 { return false }
 func (m *mockAgent) FirstMessageIndex() int                              { return 0 }
-
-// TestSSEStream tests the SSE stream endpoint
+func (m *mockAgent) DetectGitChanges(ctx context.Context)                {} // TestSSEStream tests the SSE stream endpoint
 func TestSSEStream(t *testing.T) {
 	// Create a mock agent with initial messages
 	mockAgent := &mockAgent{