Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ export const globalSettingsSchema = z.object({

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

preserveHtmlEntities: z.boolean().optional(),

rateLimitSeconds: z.number().optional(),
diffEnabled: z.boolean().optional(),
fuzzyMatchThreshold: z.number().optional(),
Expand Down
10 changes: 9 additions & 1 deletion src/core/tools/applyDiffTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,15 @@ export async function applyDiffToolLegacy(
const relPath: string | undefined = block.params.path
let diffContent: string | undefined = block.params.diff

if (diffContent && !cline.api.getModel().id.includes("claude")) {
// Get the preserveHtmlEntities setting from the provider
const provider = cline.providerRef.deref()
const state = await provider?.getState()
const preserveHtmlEntities = state?.preserveHtmlEntities ?? false

// Only unescape HTML entities if:
// 1. The setting is not explicitly set to preserve them, AND
// 2. The model is not Claude (Claude handles entities correctly by default)
if (diffContent && !preserveHtmlEntities && !cline.api.getModel().id.includes("claude")) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] The HTML-entity handling condition is duplicated across multiple tools (applyDiffTool, multiApplyDiffTool, writeToFileTool, executeCommandTool). Consider extracting a small shared helper, e.g., shouldUnescapeHtmlEntities({ preserveHtmlEntities, modelId }), to keep behavior consistent and prevent drift.

diffContent = unescapeHtmlEntities(diffContent)
}

Expand Down
16 changes: 13 additions & 3 deletions src/core/tools/executeCommandTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,26 @@ export async function executeCommandTool(

task.consecutiveMistakeCount = 0

command = unescapeHtmlEntities(command) // Unescape HTML entities.
// Get the preserveHtmlEntities setting from the provider
const provider = task.providerRef.deref()
const providerState = await provider?.getState()
const preserveHtmlEntities = providerState?.preserveHtmlEntities ?? false

// Unescape HTML entities in the command if the setting allows it.
// This is necessary because some models may escape special characters
// like <, >, &, etc. in their output, which would break command execution.
// Only unescape if the setting is not explicitly set to preserve them.
if (!preserveHtmlEntities) {
command = unescapeHtmlEntities(command)
}

const didApprove = await askApproval("command", command)

if (!didApprove) {
return
}

const executionId = task.lastMessageTs?.toString() ?? Date.now().toString()
const provider = await task.providerRef.deref()
const providerState = await provider?.getState()

const {
terminalOutputLineLimit = 500,
Expand Down
28 changes: 17 additions & 11 deletions src/core/tools/multiApplyDiffTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,10 @@ export async function applyDiffTool(
) {
// Check if MULTI_FILE_APPLY_DIFF experiment is enabled
const provider = cline.providerRef.deref()
let preserveHtmlEntities = false
if (provider) {
const state = await provider.getState()
preserveHtmlEntities = state?.preserveHtmlEntities ?? false
const isMultiFileApplyDiffEnabled = experiments.isEnabled(
state.experiments ?? {},
EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF,
Expand Down Expand Up @@ -422,12 +424,16 @@ Original error: ${errorMessage}`
let formattedError = ""

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

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

// Check if preventFocusDisruption experiment is enabled
const provider = cline.providerRef.deref()
const state = await provider?.getState()
const diagnosticsEnabled = state?.diagnosticsEnabled ?? true
const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS
const providerForExperiment = cline.providerRef.deref()
const stateForExperiment = await providerForExperiment?.getState()
const diagnosticsEnabled = stateForExperiment?.diagnosticsEnabled ?? true
const writeDelayMs = stateForExperiment?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS
const isPreventFocusDisruptionEnabled = experiments.isEnabled(
state?.experiments ?? {},
stateForExperiment?.experiments ?? {},
EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION,
)

Expand Down
10 changes: 9 additions & 1 deletion src/core/tools/writeToFileTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,15 @@ export async function writeToFileTool(
newContent = newContent.split("\n").slice(0, -1).join("\n")
}

if (!cline.api.getModel().id.includes("claude")) {
// Get the preserveHtmlEntities setting from the provider
const provider = cline.providerRef.deref()
const state = await provider?.getState()
const preserveHtmlEntities = state?.preserveHtmlEntities ?? false

// Only unescape HTML entities if:
// 1. The setting is not explicitly set to preserve them, AND
// 2. The model is not Claude (Claude handles entities correctly by default)
if (!preserveHtmlEntities && !cline.api.getModel().id.includes("claude")) {
newContent = unescapeHtmlEntities(newContent)
}

Expand Down
2 changes: 2 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1870,6 +1870,7 @@ export class ClineProvider
ttsEnabled: ttsEnabled ?? false,
ttsSpeed: ttsSpeed ?? 1.0,
diffEnabled: diffEnabled ?? true,
preserveHtmlEntities: this.getGlobalState("preserveHtmlEntities") ?? false,
enableCheckpoints: enableCheckpoints ?? true,
shouldShowAnnouncement:
telemetrySetting !== "unset" && lastShownAnnouncementId !== this.latestAnnouncementId,
Expand Down Expand Up @@ -2092,6 +2093,7 @@ export class ClineProvider
ttsEnabled: stateValues.ttsEnabled ?? false,
ttsSpeed: stateValues.ttsSpeed ?? 1.0,
diffEnabled: stateValues.diffEnabled ?? true,
preserveHtmlEntities: stateValues.preserveHtmlEntities ?? false,
enableCheckpoints: stateValues.enableCheckpoints ?? true,
soundVolume: stateValues.soundVolume,
browserViewportSize: stateValues.browserViewportSize ?? "900x600",
Expand Down
4 changes: 4 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1373,6 +1373,10 @@ export const webviewMessageHandler = async (
await updateGlobalState("diagnosticsEnabled", message.bool ?? true)
await provider.postStateToWebview()
break
case "preserveHtmlEntities":
await updateGlobalState("preserveHtmlEntities", message.bool ?? false)
await provider.postStateToWebview()
break
case "terminalOutputLineLimit":
// Validate that the line limit is a positive number
const lineLimit = message.value
Expand Down
1 change: 1 addition & 0 deletions src/shared/ExtensionMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ export type ExtensionState = Pick<
| "terminalZdotdir"
| "terminalCompressProgressBar"
| "diagnosticsEnabled"
| "preserveHtmlEntities"
| "diffEnabled"
| "fuzzyMatchThreshold"
// | "experiments" // Optional in GlobalSettings, required here.
Expand Down
1 change: 1 addition & 0 deletions src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export interface WebviewMessage {
| "fuzzyMatchThreshold"
| "writeDelayMs"
| "diagnosticsEnabled"
| "preserveHtmlEntities"
| "enhancePrompt"
| "enhancedPrompt"
| "draggedImages"
Expand Down
64 changes: 64 additions & 0 deletions src/utils/__tests__/html-entity-preservation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, it, expect } from "vitest"
import { unescapeHtmlEntities } from "../text-normalization"

describe("HTML Entity Preservation", () => {
describe("unescapeHtmlEntities", () => {
it("should unescape basic HTML entities", () => {
expect(unescapeHtmlEntities("&lt;div&gt;")).toBe("<div>")
expect(unescapeHtmlEntities("&amp;")).toBe("&")
expect(unescapeHtmlEntities("&quot;")).toBe('"')
expect(unescapeHtmlEntities("&#39;")).toBe("'")
})

it("should handle complex HTML with multiple entities", () => {
const input = "&lt;a href=&quot;https://example.com?param1=value&amp;param2=value&quot;&gt;Link&lt;/a&gt;"
const expected = '<a href="https://example.com?param1=value&param2=value">Link</a>'
expect(unescapeHtmlEntities(input)).toBe(expected)
})

it("should preserve text without entities", () => {
const text = "Plain text without entities"
expect(unescapeHtmlEntities(text)).toBe(text)
})

it("should handle empty or undefined input", () => {
expect(unescapeHtmlEntities("")).toBe("")
expect(unescapeHtmlEntities(undefined as unknown as string)).toBe(undefined)
})

it("should unescape square bracket entities", () => {
expect(unescapeHtmlEntities("array&#91;0&#93;")).toBe("array[0]")
expect(unescapeHtmlEntities("string&lsqb;&rsqb;")).toBe("string[]")
})
})

describe("Setting-based HTML entity handling", () => {
it("should document that preserveHtmlEntities=false triggers unescaping", () => {
// When preserveHtmlEntities is false (default for non-Claude models),
// HTML entities should be unescaped
const input = "&lt;test&gt;"
const output = unescapeHtmlEntities(input)
expect(output).toBe("<test>")
})

it("should document that preserveHtmlEntities=true skips unescaping", () => {
// When preserveHtmlEntities is true, the content should remain as-is
// This is tested by NOT calling unescapeHtmlEntities
const input = "&lt;test&gt;"
// In actual usage, when preserveHtmlEntities=true, we skip calling unescapeHtmlEntities
expect(input).toBe("&lt;test&gt;")
})

it("should handle code with HTML-like syntax", () => {
const codeWithHtml = "if (x &lt; 10 &amp;&amp; y &gt; 5) { return true; }"
const expected = "if (x < 10 && y > 5) { return true; }"
expect(unescapeHtmlEntities(codeWithHtml)).toBe(expected)
})

it("should handle XML/JSX code", () => {
const jsx = "&lt;Component prop=&quot;value&quot;&gt;{children}&lt;/Component&gt;"
const expected = '<Component prop="value">{children}</Component>'
expect(unescapeHtmlEntities(jsx)).toBe(expected)
})
})
})
19 changes: 18 additions & 1 deletion webview-ui/src/components/settings/AutoApproveSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type AutoApproveSettingsProps = HTMLAttributes<HTMLDivElement> & {
allowedMaxRequests?: number | undefined
allowedMaxCost?: number | undefined
deniedCommands?: string[]
preserveHtmlEntities?: boolean
setCachedStateField: SetCachedStateField<
| "alwaysAllowReadOnly"
| "alwaysAllowReadOnlyOutsideWorkspace"
Expand All @@ -57,6 +58,7 @@ type AutoApproveSettingsProps = HTMLAttributes<HTMLDivElement> & {
| "allowedMaxCost"
| "deniedCommands"
| "alwaysAllowUpdateTodoList"
| "preserveHtmlEntities"
>
}

Expand All @@ -80,6 +82,7 @@ export const AutoApproveSettings = ({
allowedMaxRequests,
allowedMaxCost,
deniedCommands,
preserveHtmlEntities,
setCachedStateField,
...props
}: AutoApproveSettingsProps) => {
Expand Down Expand Up @@ -238,10 +241,24 @@ export const AutoApproveSettings = ({
data-testid="always-allow-write-protected-checkbox">
<span className="font-medium">{t("settings:autoApprove.write.protected.label")}</span>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm mt-1 mb-3">
<div className="text-vscode-descriptionForeground text-sm mt-1">
{t("settings:autoApprove.write.protected.description")}
</div>
</div>
<div>
<VSCodeCheckbox
checked={preserveHtmlEntities}
onChange={(e: any) => {
setCachedStateField("preserveHtmlEntities", e.target.checked)
vscode.postMessage({ type: "preserveHtmlEntities", bool: e.target.checked })
}}
data-testid="preserve-html-entities-checkbox">
<span className="font-medium">{t("settings:advanced.preserveHtmlEntities.label")}</span>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm mt-1 mb-3">
{t("settings:advanced.preserveHtmlEntities.description")}
</div>
</div>
</div>
)}

Expand Down
2 changes: 2 additions & 0 deletions webview-ui/src/components/settings/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
openRouterImageApiKey,
openRouterImageGenerationSelectedModel,
reasoningBlockCollapsed,
preserveHtmlEntities,
} = cachedState

const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration])
Expand Down Expand Up @@ -689,6 +690,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
allowedMaxRequests={allowedMaxRequests ?? undefined}
allowedMaxCost={allowedMaxCost ?? undefined}
deniedCommands={deniedCommands}
preserveHtmlEntities={preserveHtmlEntities}
setCachedStateField={setCachedStateField}
/>
)}
Expand Down
12 changes: 12 additions & 0 deletions webview-ui/src/context/ExtensionStateContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ export interface ExtensionStateContextType extends ExtensionState {
setMaxDiagnosticMessages: (value: number) => void
includeTaskHistoryInEnhance?: boolean
setIncludeTaskHistoryInEnhance: (value: boolean) => void
preserveHtmlEntities?: boolean
setPreserveHtmlEntities: (value: boolean) => void
}

export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
Expand Down Expand Up @@ -285,6 +287,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
global: {},
})
const [includeTaskHistoryInEnhance, setIncludeTaskHistoryInEnhance] = useState(true)
const [preserveHtmlEntities, setPreserveHtmlEntities] = useState(false)

const setListApiConfigMeta = useCallback(
(value: ProviderSettingsEntry[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })),
Expand Down Expand Up @@ -322,6 +325,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
if ((newState as any).includeTaskHistoryInEnhance !== undefined) {
setIncludeTaskHistoryInEnhance((newState as any).includeTaskHistoryInEnhance)
}
// Update preserveHtmlEntities if present in state message
if ((newState as any).preserveHtmlEntities !== undefined) {
setPreserveHtmlEntities((newState as any).preserveHtmlEntities)
}
// Handle marketplace data if present in state message
if (newState.marketplaceItems !== undefined) {
setMarketplaceItems(newState.marketplaceItems)
Expand Down Expand Up @@ -559,6 +566,11 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
},
includeTaskHistoryInEnhance,
setIncludeTaskHistoryInEnhance,
preserveHtmlEntities,
setPreserveHtmlEntities: (value) => {
setPreserveHtmlEntities(value)
vscode.postMessage({ type: "preserveHtmlEntities", bool: value })
},
}

return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>
Expand Down
4 changes: 4 additions & 0 deletions webview-ui/src/i18n/locales/ca/settings.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions webview-ui/src/i18n/locales/de/settings.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions webview-ui/src/i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,10 @@
"title": "Advanced settings"
},
"advanced": {
"preserveHtmlEntities": {
"label": "Preserve HTML entities",
"description": "When enabled, HTML entities like &lt;, &gt;, and &amp; will be preserved as-is in file edits and commands. When disabled, they will be automatically converted to their literal characters (<, >, &). Note: Claude models always preserve entities regardless of this setting."
},
"diff": {
"label": "Enable editing through diffs",
"description": "When enabled, Roo will be able to edit files more quickly and will automatically reject truncated full-file writes. Works best with the latest Claude 3.7 Sonnet model.",
Expand Down
4 changes: 4 additions & 0 deletions webview-ui/src/i18n/locales/es/settings.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions webview-ui/src/i18n/locales/fr/settings.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading