Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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)
})
})
})
17 changes: 16 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 @@ -180,6 +183,18 @@ export const AutoApproveSettings = ({
onMaxRequestsChange={(value) => setCachedStateField("allowedMaxRequests", value)}
onMaxCostChange={(value) => setCachedStateField("allowedMaxCost", value)}
/>

<div>
<VSCodeCheckbox
checked={preserveHtmlEntities}
onChange={(e: any) => setCachedStateField("preserveHtmlEntities", 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>

{/* ADDITIONAL SETTINGS */}
Expand Down Expand Up @@ -238,7 +253,7 @@ 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>
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