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