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")
diff --git a/loop/server/loophttp.go b/loop/server/loophttp.go
index 609b546..3b93d3e 100644
--- a/loop/server/loophttp.go
+++ b/loop/server/loophttp.go
@@ -99,6 +99,8 @@
 	SkabandAddr          string                        `json:"skaband_addr,omitempty"`          // URL of the skaband server
 	LinkToGitHub         bool                          `json:"link_to_github,omitempty"`        // Enable GitHub branch linking in UI
 	SSHConnectionString  string                        `json:"ssh_connection_string,omitempty"` // SSH connection string for container
+	DiffLinesAdded       int                           `json:"diff_lines_added"`                // Lines added from sketch-base to HEAD
+	DiffLinesRemoved     int                           `json:"diff_lines_removed"`              // Lines removed from sketch-base to HEAD
 }
 
 type InitRequest struct {
@@ -1279,6 +1281,9 @@
 	serverMessageCount := s.agent.MessageCount()
 	totalUsage := s.agent.TotalUsage()
 
+	// Get diff stats
+	diffAdded, diffRemoved := s.agent.DiffStats()
+
 	return State{
 		StateVersion: 2,
 		MessageCount: serverMessageCount,
@@ -1310,6 +1315,8 @@
 		SkabandAddr:          s.agent.SkabandAddr(),
 		LinkToGitHub:         s.agent.LinkToGitHub(),
 		SSHConnectionString:  s.agent.SSHConnectionString(),
+		DiffLinesAdded:       diffAdded,
+		DiffLinesRemoved:     diffRemoved,
 	}
 }
 
diff --git a/loop/server/loophttp_test.go b/loop/server/loophttp_test.go
index 531f961..d93c360 100644
--- a/loop/server/loophttp_test.go
+++ b/loop/server/loophttp_test.go
@@ -262,6 +262,7 @@
 func (m *mockAgent) GetPortMonitor() *loop.PortMonitor { return loop.NewPortMonitor() }
 func (m *mockAgent) SkabandAddr() string               { return m.skabandAddr }
 func (m *mockAgent) LinkToGitHub() bool                { return false }
+func (m *mockAgent) DiffStats() (int, int)             { return 0, 0 }
 
 // TestSSEStream tests the SSE stream endpoint
 func TestSSEStream(t *testing.T) {
diff --git a/webui/src/fixtures/dummy.ts b/webui/src/fixtures/dummy.ts
index 87b73bd..2d8d91f 100644
--- a/webui/src/fixtures/dummy.ts
+++ b/webui/src/fixtures/dummy.ts
@@ -376,4 +376,6 @@
   in_container: true,
   first_message_index: 0,
   agent_state: "WaitingForUserInput",
+  diff_lines_added: 42,
+  diff_lines_removed: 7,
 };
diff --git a/webui/src/types.ts b/webui/src/types.ts
index 6b020e6..d764370 100644
--- a/webui/src/types.ts
+++ b/webui/src/types.ts
@@ -91,6 +91,8 @@
 	skaband_addr?: string;
 	link_to_github?: boolean;
 	ssh_connection_string?: string;
+	diff_lines_added: number;
+	diff_lines_removed: number;
 }
 
 export interface TodoItem {
diff --git a/webui/src/web-components/sketch-app-shell.ts b/webui/src/web-components/sketch-app-shell.ts
index 29e12ba..e2d27a2 100644
--- a/webui/src/web-components/sketch-app-shell.ts
+++ b/webui/src/web-components/sketch-app-shell.ts
@@ -531,6 +531,8 @@
     ssh_error: "",
     in_container: false,
     first_message_index: 0,
+    diff_lines_added: 0,
+    diff_lines_removed: 0,
   };
 
   // Mutation observer to detect when new messages are added
@@ -1238,7 +1240,10 @@
         <!-- Last Commit section moved to sketch-container-status -->
 
         <!-- Views section with tabs -->
-        <sketch-view-mode-select></sketch-view-mode-select>
+        <sketch-view-mode-select
+          .diffLinesAdded=${this.containerState?.diff_lines_added || 0}
+          .diffLinesRemoved=${this.containerState?.diff_lines_removed || 0}
+        ></sketch-view-mode-select>
 
         <div class="refresh-control">
           <button
diff --git a/webui/src/web-components/sketch-container-status.test.ts b/webui/src/web-components/sketch-container-status.test.ts
index ca1a8f5..7d01ac7 100644
--- a/webui/src/web-components/sketch-container-status.test.ts
+++ b/webui/src/web-components/sketch-container-status.test.ts
@@ -27,6 +27,8 @@
   ssh_available: false,
   in_container: true,
   first_message_index: 0,
+  diff_lines_added: 15,
+  diff_lines_removed: 3,
 };
 
 test("render props", async ({ mount }) => {
diff --git a/webui/src/web-components/sketch-view-mode-select.ts b/webui/src/web-components/sketch-view-mode-select.ts
index c83b964..c6385d3 100644
--- a/webui/src/web-components/sketch-view-mode-select.ts
+++ b/webui/src/web-components/sketch-view-mode-select.ts
@@ -7,6 +7,14 @@
   // Current active mode
   @property()
   activeMode: "chat" | "diff2" | "terminal" = "chat";
+
+  // Diff stats
+  @property({ type: Number })
+  diffLinesAdded: number = 0;
+
+  @property({ type: Number })
+  diffLinesRemoved: number = 0;
+
   // Header bar: view mode buttons
 
   static styles = css`
@@ -36,13 +44,32 @@
     }
 
     @media (max-width: 1400px) {
-      .tab-btn span:not(.tab-icon) {
+      .tab-btn span:not(.tab-icon):not(.diff-stats) {
         display: none;
       }
 
       .tab-btn {
         padding: 8px 10px;
       }
+
+      /* Always show diff stats */
+      .diff-stats {
+        display: inline !important;
+        font-size: 11px;
+        margin-left: 2px;
+      }
+    }
+
+    /* Style for diff stats */
+    .diff-stats {
+      font-size: 11px;
+      margin-left: 4px;
+      color: inherit;
+      opacity: 0.8;
+    }
+
+    .tab-btn.active .diff-stats {
+      opacity: 1;
     }
 
     .tab-btn:not(:last-child) {
@@ -133,11 +160,19 @@
         <button
           id="showDiff2Button"
           class="tab-btn ${this.activeMode === "diff2" ? "active" : ""}"
-          title="Diff View"
+          title="Diff View - ${this.diffLinesAdded > 0 ||
+          this.diffLinesRemoved > 0
+            ? `+${this.diffLinesAdded} -${this.diffLinesRemoved}`
+            : "No changes"}"
           @click=${() => this._handleViewModeClick("diff2")}
         >
           <span class="tab-icon">±</span>
-          <span>Diff</span>
+          <span class="diff-text">Diff</span>
+          ${this.diffLinesAdded > 0 || this.diffLinesRemoved > 0
+            ? html`<span class="diff-stats"
+                >+${this.diffLinesAdded} -${this.diffLinesRemoved}</span
+              >`
+            : ""}
         </button>
 
         <button