webui: add diff display for patches

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s2e9bdfb014ddec3ck
diff --git a/claudetool/patch.go b/claudetool/patch.go
index 815cf88..04ea942 100644
--- a/claudetool/patch.go
+++ b/claudetool/patch.go
@@ -13,6 +13,7 @@
 	"path/filepath"
 	"strings"
 
+	"github.com/pkg/diff"
 	"sketch.dev/claudetool/editbuf"
 	"sketch.dev/claudetool/patchkit"
 	"sketch.dev/llm"
@@ -264,8 +265,13 @@
 		fmt.Fprintf(response, "- WARNING: %q appears to be autogenerated. Patches were applied anyway.\n", input.Path)
 	}
 
+	diff := generateUnifiedDiff(input.Path, string(orig), string(patched))
+
 	// TODO: maybe report the patch result to the model, i.e. some/all of the new code after the patches and formatting.
-	return llm.ToolOut{LLMContent: llm.TextContent(response.String())}
+	return llm.ToolOut{
+		LLMContent: llm.TextContent(response.String()),
+		Display:    diff,
+	}
 }
 
 func parseGo(buf []byte) error {
@@ -318,3 +324,12 @@
 	strings.ToLower("DO NOT EDIT"),
 	strings.ToLower("export by"),
 }
+
+func generateUnifiedDiff(filePath, original, patched string) string {
+	buf := new(strings.Builder)
+	err := diff.Text(filePath, filePath, original, patched, buf)
+	if err != nil {
+		return fmt.Sprintf("(diff generation failed: %v)\n", err)
+	}
+	return buf.String()
+}
diff --git a/go.mod b/go.mod
index 207caf6..45d87c3 100644
--- a/go.mod
+++ b/go.mod
@@ -19,6 +19,7 @@
 	github.com/pkg/sftp v1.13.9
 	github.com/richardlehane/crock32 v1.0.1
 	github.com/sashabaranov/go-openai v1.38.2
+	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
 	go.skia.org/infra v0.0.0-20250421160028-59e18403fd4a
 	golang.org/x/crypto v0.37.0
 	golang.org/x/net v0.39.0
@@ -39,6 +40,7 @@
 	github.com/kr/fs v0.1.0 // indirect
 	github.com/mattn/go-colorable v0.1.13 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/pkg/diff v0.0.0-20241224192749-4e6772a4315c // indirect
 	github.com/spf13/cast v1.7.1 // indirect
 	github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
 	go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
diff --git a/go.sum b/go.sum
index e907145..24bc631 100644
--- a/go.sum
+++ b/go.sum
@@ -45,8 +45,11 @@
 github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
 github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
 github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
@@ -63,6 +66,8 @@
 github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
 github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
 github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
+github.com/pkg/diff v0.0.0-20241224192749-4e6772a4315c h1:8TRxBMS/YsupXoOiGKHr9ZOXo+5DezGWPgBAhBHEHto=
+github.com/pkg/diff v0.0.0-20241224192749-4e6772a4315c/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
 github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw=
 github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -74,10 +79,13 @@
 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
 github.com/sashabaranov/go-openai v1.38.2 h1:akrssjj+6DY3lWuDwHv6cBvJ8Z+FZDM9XEaaYFt0Auo=
 github.com/sashabaranov/go-openai v1.38.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
 github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
 github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
@@ -173,6 +181,9 @@
 golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/llm/conversation/convo.go b/llm/conversation/convo.go
index a52d494..5e20137 100644
--- a/llm/conversation/convo.go
+++ b/llm/conversation/convo.go
@@ -459,15 +459,16 @@
 				c.Listener.OnToolResult(ctx, c, part.ID, part.ToolName, part.ToolInput, content, nil, err)
 				toolResultC <- content
 			}
-			sendRes := func(toolResult []llm.Content) {
+			sendRes := func(toolOut llm.ToolOut) {
 				// Record end time
 				endTime := time.Now()
 				content.ToolUseEndTime = &endTime
 
-				content.ToolResult = toolResult
+				content.ToolResult = toolOut.LLMContent
+				content.Display = toolOut.Display
 				var firstText string
-				if len(toolResult) > 0 {
-					firstText = toolResult[0].Text
+				if len(toolOut.LLMContent) > 0 {
+					firstText = toolOut.LLMContent[0].Text
 				}
 				c.Listener.OnToolResult(ctx, c, part.ID, part.ToolName, part.ToolInput, content, &firstText, nil)
 				toolResultC <- content
@@ -497,7 +498,7 @@
 				sendErr(toolOut.Error)
 				return
 			}
-			sendRes(toolOut.LLMContent)
+			sendRes(toolOut)
 		}()
 	}
 	wg.Wait()
diff --git a/llm/llm.go b/llm/llm.go
index 638a6a3..ffaad3e 100644
--- a/llm/llm.go
+++ b/llm/llm.go
@@ -100,6 +100,10 @@
 	// LLMContent is the output of the tool to be sent back to the LLM.
 	// May be nil on error.
 	LLMContent []Content
+	// Display is content to be displayed to the user.
+	// The type of content is set by the tool and coordinated with the UIs.
+	// It should be JSON-serializable.
+	Display any
 	// Error is the error (if any) that occurred during the tool run.
 	// The text contents of the error will be sent back to the LLM.
 	// If non-nil, LLMContent will be ignored.
@@ -132,6 +136,9 @@
 	ToolUseStartTime *time.Time
 	ToolUseEndTime   *time.Time
 
+	// Display is content to be displayed to the user, copied from ToolOut
+	Display any
+
 	Cache bool
 }
 
diff --git a/loop/agent.go b/loop/agent.go
index c98e7f7..cf35eb4 100644
--- a/loop/agent.go
+++ b/loop/agent.go
@@ -224,6 +224,9 @@
 	// TodoContent contains the agent's todo file content when it has changed
 	TodoContent *string `json:"todo_content,omitempty"`
 
+	// Display contains content to be displayed to the user, set by tools
+	Display any `json:"display,omitempty"`
+
 	Idx int `json:"idx"`
 }
 
@@ -905,6 +908,7 @@
 		ToolCallId: content.ToolUseID,
 		StartTime:  content.ToolUseStartTime,
 		EndTime:    content.ToolUseEndTime,
+		Display:    content.Display,
 	}
 
 	// Calculate the elapsed time if both start and end times are set
diff --git a/webui/src/types.ts b/webui/src/types.ts
index a4f2154..217afe2 100644
--- a/webui/src/types.ts
+++ b/webui/src/types.ts
@@ -47,6 +47,7 @@
 	turnDuration?: Duration | null;
 	hide_output?: boolean;
 	todo_content?: string | null;
+	display?: any;
 	idx: number;
 }
 
diff --git a/webui/src/web-components/demo/sketch-tool-card.demo.ts b/webui/src/web-components/demo/sketch-tool-card.demo.ts
index 6b499f1..625e555 100644
--- a/webui/src/web-components/demo/sketch-tool-card.demo.ts
+++ b/webui/src/web-components/demo/sketch-tool-card.demo.ts
@@ -142,6 +142,8 @@
         result_message: {
           type: "tool",
           tool_result: "- Applied all patches\n",
+          display:
+            "@@ -1,3 +1,3 @@\n # Web Components\n \n-This directory contains the old components.\n+This directory contains custom web components...",
           tool_call_id: "toolu_01TNhLX2AWkZwsu2KCLKrpju",
         },
       },
diff --git a/webui/src/web-components/sketch-tool-card-base.ts b/webui/src/web-components/sketch-tool-card-base.ts
index 8dcc887..9018275 100644
--- a/webui/src/web-components/sketch-tool-card-base.ts
+++ b/webui/src/web-components/sketch-tool-card-base.ts
@@ -118,7 +118,9 @@
             ? html`<div class="mb-2">${this.inputContent}</div>`
             : ""}
           ${this.resultContent
-            ? html`<div class="mt-2">${this.resultContent}</div>`
+            ? html`<div class="${this.inputContent ? "mt-2" : ""}">
+                ${this.resultContent}
+              </div>`
             : ""}
         </div>
       </div>
diff --git a/webui/src/web-components/sketch-tool-card.ts b/webui/src/web-components/sketch-tool-card.ts
index 8016fbc..9c1ae62 100644
--- a/webui/src/web-components/sketch-tool-card.ts
+++ b/webui/src/web-components/sketch-tool-card.ts
@@ -1,7 +1,7 @@
 import { html } from "lit";
 import { unsafeHTML } from "lit/directives/unsafe-html.js";
 import { customElement, property } from "lit/decorators.js";
-import { ToolCall, State } from "../types";
+import { State, ToolCall } from "../types";
 import { marked } from "marked";
 import DOMPurify from "dompurify";
 import { SketchTailwindElement } from "./sketch-tailwind-element";
@@ -199,6 +199,37 @@
   @property() toolCall: ToolCall;
   @property() open: boolean;
 
+  // Render a diff with syntax highlighting
+  renderDiff(diff: string) {
+    // Remove ---/+++ header lines and trim leading/trailing blank lines
+    const lines = diff
+      .split("\n")
+      .filter((line) => !line.startsWith("---") && !line.startsWith("+++"))
+      .join("\n")
+      .trim()
+      .split("\n");
+
+    const coloredLines = lines.map((line) => {
+      if (line.startsWith("+")) {
+        return html`<div class="text-green-600 bg-green-50">${line}</div>`;
+      } else if (line.startsWith("-")) {
+        return html`<div class="text-red-600 bg-red-50">${line}</div>`;
+      } else if (line.startsWith("@@")) {
+        // prettier-ignore
+        return html`<div class="text-cyan-600 bg-cyan-50 font-semibold">${line}</div>`;
+      } else {
+        return html`<div class="text-gray-800">${line}</div>`;
+      }
+    });
+
+    return html`<pre
+      class="bg-gray-100 text-xs p-2 rounded whitespace-pre-wrap break-words max-w-full w-full box-border overflow-x-auto font-mono"
+    >
+      ${coloredLines}
+    </pre
+    >`;
+  }
+
   render() {
     const patchInput = JSON.parse(this.toolCall?.input);
 
@@ -209,18 +240,25 @@
       edit${patchInput.patches.length > 1 ? "s" : ""}
     </span>`;
 
-    const inputContent = html`<div>
-      ${patchInput.patches.map((patch) => {
-        return html`<div class="mb-2">
-          Patch operation: <b>${patch.operation}</b>
-          ${createPreElement(patch.newText)}
-        </div>`;
-      })}
-    </div>`;
+    const inputContent = html``;
 
-    const resultContent = this.toolCall?.result_message?.tool_result
-      ? createPreElement(this.toolCall.result_message.tool_result)
-      : "";
+    // Show diff if available, otherwise show the regular result
+    let resultContent;
+    if (
+      this.toolCall?.result_message?.display &&
+      typeof this.toolCall.result_message.display === "string"
+    ) {
+      // Render the diff with syntax highlighting
+      resultContent = html`<div class="w-full relative">
+        ${this.renderDiff(this.toolCall.result_message.display)}
+      </div>`;
+    } else if (this.toolCall?.result_message?.tool_result) {
+      resultContent = createPreElement(
+        this.toolCall.result_message.tool_result,
+      );
+    } else {
+      resultContent = "";
+    }
 
     return html`<sketch-tool-card-base
       .open=${this.open}