Skip to content

Commit 7782eef

Browse files
committed
feat: add option to preserve html entities
1 parent 97f9686 commit 7782eef

31 files changed

+226
-17
lines changed

packages/types/src/global-settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ export const globalSettingsSchema = z.object({
124124

125125
diagnosticsEnabled: z.boolean().optional(),
126126

127+
preserveHtmlEntities: z.boolean().optional(),
128+
127129
rateLimitSeconds: z.number().optional(),
128130
diffEnabled: z.boolean().optional(),
129131
fuzzyMatchThreshold: z.number().optional(),

src/core/tools/applyDiffTool.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,15 @@ export async function applyDiffToolLegacy(
2525
const relPath: string | undefined = block.params.path
2626
let diffContent: string | undefined = block.params.diff
2727

28-
if (diffContent && !cline.api.getModel().id.includes("claude")) {
28+
// Get the preserveHtmlEntities setting from the provider
29+
const provider = cline.providerRef.deref()
30+
const state = await provider?.getState()
31+
const preserveHtmlEntities = state?.preserveHtmlEntities ?? false
32+
33+
// Only unescape HTML entities if:
34+
// 1. The setting is not explicitly set to preserve them, AND
35+
// 2. The model is not Claude (Claude handles entities correctly by default)
36+
if (diffContent && !preserveHtmlEntities && !cline.api.getModel().id.includes("claude")) {
2937
diffContent = unescapeHtmlEntities(diffContent)
3038
}
3139

src/core/tools/executeCommandTool.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,16 +53,26 @@ export async function executeCommandTool(
5353

5454
task.consecutiveMistakeCount = 0
5555

56-
command = unescapeHtmlEntities(command) // Unescape HTML entities.
56+
// Get the preserveHtmlEntities setting from the provider
57+
const provider = task.providerRef.deref()
58+
const providerState = await provider?.getState()
59+
const preserveHtmlEntities = providerState?.preserveHtmlEntities ?? false
60+
61+
// Unescape HTML entities in the command if the setting allows it.
62+
// This is necessary because some models may escape special characters
63+
// like <, >, &, etc. in their output, which would break command execution.
64+
// Only unescape if the setting is not explicitly set to preserve them.
65+
if (!preserveHtmlEntities) {
66+
command = unescapeHtmlEntities(command)
67+
}
68+
5769
const didApprove = await askApproval("command", command)
5870

5971
if (!didApprove) {
6072
return
6173
}
6274

6375
const executionId = task.lastMessageTs?.toString() ?? Date.now().toString()
64-
const provider = await task.providerRef.deref()
65-
const providerState = await provider?.getState()
6676

6777
const {
6878
terminalOutputLineLimit = 500,

src/core/tools/multiApplyDiffTool.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,10 @@ export async function applyDiffTool(
6060
) {
6161
// Check if MULTI_FILE_APPLY_DIFF experiment is enabled
6262
const provider = cline.providerRef.deref()
63+
let preserveHtmlEntities = false
6364
if (provider) {
6465
const state = await provider.getState()
66+
preserveHtmlEntities = state?.preserveHtmlEntities ?? false
6567
const isMultiFileApplyDiffEnabled = experiments.isEnabled(
6668
state.experiments ?? {},
6769
EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF,
@@ -422,12 +424,16 @@ Original error: ${errorMessage}`
422424
let formattedError = ""
423425

424426
// Pre-process all diff items for HTML entity unescaping if needed
425-
const processedDiffItems = !cline.api.getModel().id.includes("claude")
426-
? diffItems.map((item) => ({
427-
...item,
428-
content: item.content ? unescapeHtmlEntities(item.content) : item.content,
429-
}))
430-
: diffItems
427+
// Only unescape if:
428+
// 1. The setting is not explicitly set to preserve them, AND
429+
// 2. The model is not Claude (Claude handles entities correctly by default)
430+
const processedDiffItems =
431+
!preserveHtmlEntities && !cline.api.getModel().id.includes("claude")
432+
? diffItems.map((item) => ({
433+
...item,
434+
content: item.content ? unescapeHtmlEntities(item.content) : item.content,
435+
}))
436+
: diffItems
431437

432438
// Apply all diffs at once with the array-based method
433439
const diffResult = (await cline.diffStrategy?.applyDiff(originalContent, processedDiffItems)) ?? {
@@ -518,12 +524,12 @@ ${errorDetails ? `\nTechnical details:\n${errorDetails}\n` : ""}
518524
cline.consecutiveMistakeCountForApplyDiff.delete(relPath)
519525

520526
// Check if preventFocusDisruption experiment is enabled
521-
const provider = cline.providerRef.deref()
522-
const state = await provider?.getState()
523-
const diagnosticsEnabled = state?.diagnosticsEnabled ?? true
524-
const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS
527+
const providerForExperiment = cline.providerRef.deref()
528+
const stateForExperiment = await providerForExperiment?.getState()
529+
const diagnosticsEnabled = stateForExperiment?.diagnosticsEnabled ?? true
530+
const writeDelayMs = stateForExperiment?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS
525531
const isPreventFocusDisruptionEnabled = experiments.isEnabled(
526-
state?.experiments ?? {},
532+
stateForExperiment?.experiments ?? {},
527533
EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION,
528534
)
529535

src/core/tools/writeToFileTool.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,15 @@ export async function writeToFileTool(
8383
newContent = newContent.split("\n").slice(0, -1).join("\n")
8484
}
8585

86-
if (!cline.api.getModel().id.includes("claude")) {
86+
// Get the preserveHtmlEntities setting from the provider
87+
const provider = cline.providerRef.deref()
88+
const state = await provider?.getState()
89+
const preserveHtmlEntities = state?.preserveHtmlEntities ?? false
90+
91+
// Only unescape HTML entities if:
92+
// 1. The setting is not explicitly set to preserve them, AND
93+
// 2. The model is not Claude (Claude handles entities correctly by default)
94+
if (!preserveHtmlEntities && !cline.api.getModel().id.includes("claude")) {
8795
newContent = unescapeHtmlEntities(newContent)
8896
}
8997

src/core/webview/ClineProvider.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1870,6 +1870,7 @@ export class ClineProvider
18701870
ttsEnabled: ttsEnabled ?? false,
18711871
ttsSpeed: ttsSpeed ?? 1.0,
18721872
diffEnabled: diffEnabled ?? true,
1873+
preserveHtmlEntities: this.getGlobalState("preserveHtmlEntities") ?? false,
18731874
enableCheckpoints: enableCheckpoints ?? true,
18741875
shouldShowAnnouncement:
18751876
telemetrySetting !== "unset" && lastShownAnnouncementId !== this.latestAnnouncementId,
@@ -2092,6 +2093,7 @@ export class ClineProvider
20922093
ttsEnabled: stateValues.ttsEnabled ?? false,
20932094
ttsSpeed: stateValues.ttsSpeed ?? 1.0,
20942095
diffEnabled: stateValues.diffEnabled ?? true,
2096+
preserveHtmlEntities: stateValues.preserveHtmlEntities ?? false,
20952097
enableCheckpoints: stateValues.enableCheckpoints ?? true,
20962098
soundVolume: stateValues.soundVolume,
20972099
browserViewportSize: stateValues.browserViewportSize ?? "900x600",

src/core/webview/webviewMessageHandler.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1373,6 +1373,10 @@ export const webviewMessageHandler = async (
13731373
await updateGlobalState("diagnosticsEnabled", message.bool ?? true)
13741374
await provider.postStateToWebview()
13751375
break
1376+
case "preserveHtmlEntities":
1377+
await updateGlobalState("preserveHtmlEntities", message.bool ?? false)
1378+
await provider.postStateToWebview()
1379+
break
13761380
case "terminalOutputLineLimit":
13771381
// Validate that the line limit is a positive number
13781382
const lineLimit = message.value

src/shared/ExtensionMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ export type ExtensionState = Pick<
265265
| "terminalZdotdir"
266266
| "terminalCompressProgressBar"
267267
| "diagnosticsEnabled"
268+
| "preserveHtmlEntities"
268269
| "diffEnabled"
269270
| "fuzzyMatchThreshold"
270271
// | "experiments" // Optional in GlobalSettings, required here.

src/shared/WebviewMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ export interface WebviewMessage {
114114
| "fuzzyMatchThreshold"
115115
| "writeDelayMs"
116116
| "diagnosticsEnabled"
117+
| "preserveHtmlEntities"
117118
| "enhancePrompt"
118119
| "enhancedPrompt"
119120
| "draggedImages"
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { describe, it, expect } from "vitest"
2+
import { unescapeHtmlEntities } from "../text-normalization"
3+
4+
describe("HTML Entity Preservation", () => {
5+
describe("unescapeHtmlEntities", () => {
6+
it("should unescape basic HTML entities", () => {
7+
expect(unescapeHtmlEntities("&lt;div&gt;")).toBe("<div>")
8+
expect(unescapeHtmlEntities("&amp;")).toBe("&")
9+
expect(unescapeHtmlEntities("&quot;")).toBe('"')
10+
expect(unescapeHtmlEntities("&#39;")).toBe("'")
11+
})
12+
13+
it("should handle complex HTML with multiple entities", () => {
14+
const input = "&lt;a href=&quot;https://example.com?param1=value&amp;param2=value&quot;&gt;Link&lt;/a&gt;"
15+
const expected = '<a href="https://example.com?param1=value&param2=value">Link</a>'
16+
expect(unescapeHtmlEntities(input)).toBe(expected)
17+
})
18+
19+
it("should preserve text without entities", () => {
20+
const text = "Plain text without entities"
21+
expect(unescapeHtmlEntities(text)).toBe(text)
22+
})
23+
24+
it("should handle empty or undefined input", () => {
25+
expect(unescapeHtmlEntities("")).toBe("")
26+
expect(unescapeHtmlEntities(undefined as unknown as string)).toBe(undefined)
27+
})
28+
29+
it("should unescape square bracket entities", () => {
30+
expect(unescapeHtmlEntities("array&#91;0&#93;")).toBe("array[0]")
31+
expect(unescapeHtmlEntities("string&lsqb;&rsqb;")).toBe("string[]")
32+
})
33+
})
34+
35+
describe("Setting-based HTML entity handling", () => {
36+
it("should document that preserveHtmlEntities=false triggers unescaping", () => {
37+
// When preserveHtmlEntities is false (default for non-Claude models),
38+
// HTML entities should be unescaped
39+
const input = "&lt;test&gt;"
40+
const output = unescapeHtmlEntities(input)
41+
expect(output).toBe("<test>")
42+
})
43+
44+
it("should document that preserveHtmlEntities=true skips unescaping", () => {
45+
// When preserveHtmlEntities is true, the content should remain as-is
46+
// This is tested by NOT calling unescapeHtmlEntities
47+
const input = "&lt;test&gt;"
48+
// In actual usage, when preserveHtmlEntities=true, we skip calling unescapeHtmlEntities
49+
expect(input).toBe("&lt;test&gt;")
50+
})
51+
52+
it("should handle code with HTML-like syntax", () => {
53+
const codeWithHtml = "if (x &lt; 10 &amp;&amp; y &gt; 5) { return true; }"
54+
const expected = "if (x < 10 && y > 5) { return true; }"
55+
expect(unescapeHtmlEntities(codeWithHtml)).toBe(expected)
56+
})
57+
58+
it("should handle XML/JSX code", () => {
59+
const jsx = "&lt;Component prop=&quot;value&quot;&gt;{children}&lt;/Component&gt;"
60+
const expected = '<Component prop="value">{children}</Component>'
61+
expect(unescapeHtmlEntities(jsx)).toBe(expected)
62+
})
63+
})
64+
})

0 commit comments

Comments
 (0)