loop: add diff stats from sketch-base to HEAD in /state endpoint

Add lines added/removed statistics computed from sketch-base to current HEAD,
displayed in webui Diff mode button for quick change overview.

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s3f10ecf39df6b581k
diff --git a/loop/agent.go b/loop/agent.go
index a4d2505..561e986 100644
--- a/loop/agent.go
+++ b/loop/agent.go
@@ -130,6 +130,9 @@
 	OutsideHostname() string
 	OutsideWorkingDir() string
 	GitOrigin() string
+
+	// DiffStats returns the number of lines added and removed from sketch-base to HEAD
+	DiffStats() (int, int)
 	// OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
 	OpenBrowser(url string)
 
@@ -334,6 +337,8 @@
 	seenCommits   map[string]bool // Track git commits we've already seen (by hash)
 	slug          string          // Human-readable session identifier
 	retryNumber   int             // Number to append when branch conflicts occur
+	linesAdded    int             // Lines added from sketch-base to HEAD
+	linesRemoved  int             // Lines removed from sketch-base to HEAD
 }
 
 func (ags *AgentGitState) SetSlug(slug string) {
@@ -357,6 +362,12 @@
 	ags.retryNumber++
 }
 
+func (ags *AgentGitState) DiffStats() (int, int) {
+	ags.mu.Lock()
+	defer ags.mu.Unlock()
+	return ags.linesAdded, ags.linesRemoved
+}
+
 // HasSeenCommits returns true if any commits have been processed
 func (ags *AgentGitState) HasSeenCommits() bool {
 	ags.mu.Lock()
@@ -706,6 +717,11 @@
 	return a.gitOrigin
 }
 
+// DiffStats returns the number of lines added and removed from sketch-base to HEAD
+func (a *Agent) DiffStats() (int, int) {
+	return a.gitState.DiffStats()
+}
+
 func (a *Agent) OpenBrowser(url string) {
 	if !a.IsInContainer() {
 		browser.Open(url)
@@ -1903,6 +1919,16 @@
 		ags.lastSketch = sketch
 	}()
 
+	// Compute diff stats from baseRef to HEAD when HEAD changes
+	if added, removed, err := computeDiffStats(ctx, repoRoot, baseRef); err != nil {
+		// Log error but don't fail the entire operation
+		slog.WarnContext(ctx, "Failed to compute diff stats", "error", err)
+	} else {
+		// Set diff stats directly since we already hold the mutex
+		ags.linesAdded = added
+		ags.linesRemoved = removed
+	}
+
 	// Get new commits. Because it's possible that the agent does rebases, fixups, and
 	// so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
 	// to the last 100 commits.
@@ -2121,6 +2147,37 @@
 	return true
 }
 
+// computeDiffStats computes the number of lines added and removed from baseRef to HEAD
+func computeDiffStats(ctx context.Context, repoRoot, baseRef string) (int, int, error) {
+	cmd := exec.CommandContext(ctx, "git", "diff", "--numstat", baseRef, "HEAD")
+	cmd.Dir = repoRoot
+	out, err := cmd.Output()
+	if err != nil {
+		return 0, 0, fmt.Errorf("git diff --numstat failed: %w", err)
+	}
+
+	var totalAdded, totalRemoved int
+	lines := strings.Split(strings.TrimSpace(string(out)), "\n")
+	for _, line := range lines {
+		if line == "" {
+			continue
+		}
+		parts := strings.Fields(line)
+		if len(parts) < 2 {
+			continue
+		}
+		// Format: <added>\t<removed>\t<filename>
+		if added, err := strconv.Atoi(parts[0]); err == nil {
+			totalAdded += added
+		}
+		if removed, err := strconv.Atoi(parts[1]); err == nil {
+			totalRemoved += removed
+		}
+	}
+
+	return totalAdded, totalRemoved, nil
+}
+
 // getGitOrigin returns the URL of the git remote 'origin' if it exists
 func getGitOrigin(ctx context.Context, dir string) string {
 	cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")