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}