diff --git a/ui/src/components/MarkdownEditor.test.tsx b/ui/src/components/MarkdownEditor.test.tsx
index 9831b3a61f4..b219a713765 100644
--- a/ui/src/components/MarkdownEditor.test.tsx
+++ b/ui/src/components/MarkdownEditor.test.tsx
@@ -1053,6 +1053,27 @@ describe("MarkdownEditor", () => {
});
});
+ it("stores a pasted command's `=` as typed instead of the highlight exporter's escaped form", async () => {
+ const value = "Run node -e 'const fs=require(\"fs\");x.map(a=>a)' then check a == b.\n\n```js\nconst keep\\=1\n```";
+ // MDXEditor's highlight extension escapes every prose `=`; code is exported as typed.
+ mdxEditorMockState.emitMountChange =
+ "Run node -e 'const fs\\=require(\"fs\");x.map(a\\=>a)' then check a \\=\\= b.\n\n```js\nconst keep\\=1\n```";
+ const handleChange = vi.fn();
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render();
+ });
+
+ await flush();
+
+ expect(handleChange).toHaveBeenCalledWith(value);
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
+
it("keeps a code span after an escaped HTML-like tag byte-identical", async () => {
const stored = "
\n`a\\_b` and snake_case";
// The exporter escapes the `<` (reversed on storage) and the prose underscore.
diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx
index fc69ef6ff85..bc1b176dbb1 100644
--- a/ui/src/components/MarkdownEditor.tsx
+++ b/ui/src/components/MarkdownEditor.tsx
@@ -59,6 +59,7 @@ import {
} from "../lib/angle-bracket-markdown";
import { unescapeBlockquoteMarkers } from "../lib/blockquote-markdown";
import { unescapeIntrawordUnderscores } from "../lib/underscore-markdown";
+import { unescapeEqualsSigns } from "../lib/equals-markdown";
import { restoreTrailingMarkdownSpaces } from "../lib/trailing-markdown-spaces";
import { pasteNormalizationPlugin } from "../lib/paste-normalization";
import { cn } from "../lib/utils";
@@ -191,9 +192,9 @@ function prepareMarkdownForEditor(value: string): string {
*/
function toStoredMarkdown(markdown: string): string {
const withTrailingSpaces = restoreTrailingMarkdownSpaces(markdown);
- // Unescape underscores first, on the exporter's exact text: removing `\<` or
- // `\>` can turn following lines into HTML or blockquotes and move code ranges.
- return unescapeAngleBracketEscapes(unescapeBlockquoteMarkers(unescapeIntrawordUnderscores(withTrailingSpaces)));
+ // Unescape underscores and `=` first, on the exporter's exact text: removing `\<`
+ // or `\>` can turn following lines into HTML or blockquotes and move code ranges.
+ return unescapeAngleBracketEscapes(unescapeBlockquoteMarkers(unescapeEqualsSigns(unescapeIntrawordUnderscores(withTrailingSpaces))));
}
function escapeRegExp(value: string): string {
diff --git a/ui/src/lib/equals-markdown.test.ts b/ui/src/lib/equals-markdown.test.ts
new file mode 100644
index 00000000000..d58de41cc9f
--- /dev/null
+++ b/ui/src/lib/equals-markdown.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from "vitest";
+import { unescapeEqualsSigns } from "./equals-markdown";
+
+const unchanged = (value: string) => expect(unescapeEqualsSigns(value)).toBe(value);
+
+describe("unescapeEqualsSigns", () => {
+ it("restores `=` the person typed in commands, assignments and arrows", () => {
+ expect(unescapeEqualsSigns("Run node -e 'const fs\\=require(\"fs\");x.map(a\\=>a)'"))
+ .toBe("Run node -e 'const fs=require(\"fs\");x.map(a=>a)'");
+ expect(unescapeEqualsSigns("Set KEY\\=value and a \\= b")).toBe("Set KEY=value and a = b");
+ expect(unescapeEqualsSigns("strict x \\=\\=\\= y and x!\\=\\=\\=y")).toBe("strict x === y and x!===y");
+ expect(unescapeEqualsSigns("compare a \\=\\= b")).toBe("compare a == b");
+ expect(unescapeEqualsSigns("> quoted a\\=b\n\n- listed c\\=d")).toBe("> quoted a=b\n\n- listed c=d");
+ });
+
+ it("keeps `==` escaped wherever it could open or close a highlight", () => {
+ for (const value of [
+ "\\=\\=highlight\\=\\=",
+ "a\\=\\=b",
+ // `!==` before a letter can open a highlight, so it stays escaped.
+ "x!\\=\\=y",
+ // A line of `=` under text is a heading underline.
+ "Title\n\\=\\=\\=",
+ "Title\n\\=",
+ "> Title\n> \\=\\=",
+ "- item\n \\=",
+ // The exporter escapes a leading `>` too; it still starts the line.
+ "\\> Title\n\\> \\=\\=\\=",
+ "\\> Title\n\\> \\=\\= ",
+ "say \\=\\=this",
+ "that\\=\\= now",
+ "a\\\\=b",
+ ]) unchanged(value);
+ });
+
+ it("keeps every code span and code block byte-identical", () => {
+ for (const value of [
+ "Use `a\\=b` literally",
+ "```\nconst a\\=1\n```",
+ " indented a\\=b",
+ "> ```\n> a\\=b\n> ```",
+ "x",
+ ]) unchanged(value);
+ expect(unescapeEqualsSigns("prose a\\=b and `code a\\=b`")).toBe("prose a=b and `code a\\=b`");
+ // Pairs cut off by a code span do not see whitespace at the chunk edge.
+ unchanged("x \\=\\=`code`\\=\\= y");
+ });
+
+ it("keeps an escape after an escaped backslash and removes it after an odd run", () => {
+ // `\\\=` is an escaped backslash followed by an escaped `=`; only the `=` escape goes.
+ expect(unescapeEqualsSigns("a\\\\\\=b")).toBe("a\\\\=b");
+ unchanged("a\\\\=b");
+ });
+
+ it("stays linear on one long line with many assignments", () => {
+ const line = Array.from({ length: 25_000 }, (_, index) => `k${index}\\=v`).join(" ");
+ const started = performance.now();
+ expect(unescapeEqualsSigns(line)).toBe(line.replaceAll("\\=", "="));
+ expect(performance.now() - started).toBeLessThan(1_000);
+ });
+
+ it("leaves text without escaped equals untouched", () => {
+ unchanged("plain a=b");
+ unchanged("");
+ });
+});
diff --git a/ui/src/lib/equals-markdown.ts b/ui/src/lib/equals-markdown.ts
new file mode 100644
index 00000000000..568befecbb4
--- /dev/null
+++ b/ui/src/lib/equals-markdown.ts
@@ -0,0 +1,55 @@
+import { isEscaped, rewriteMarkdownProse } from "./underscore-markdown";
+
+/**
+ * MDXEditor's `==highlight==` extension (`mdast-util-highlight-mark`) marks
+ * every `=` in prose as unsafe, so the exporter stores `a=b` as `a\=b` and
+ * `() => x` as `()\=> x`. Stored text feeds agent prompts, so a pasted command
+ * or snippet reaches the agent with backslashes the person never typed.
+ *
+ * A highlight opens or closes only on a run of exactly two `=`, and a two-run
+ * with whitespace on both sides can do neither. A run that starts a line can
+ * underline a heading, so it keeps its escape. Other escapes cannot change how
+ * the text parses, so they are removed. Code
+ * spans, code blocks and raw HTML stay byte-identical, as do escaped
+ * backslashes (`\\=`).
+ */
+const ESCAPED_EQUALS_RUN_RE = /(?:\\=)+/g;
+
+function unescapeProse(text: string): string {
+ // One forward pass: matches arrive in order, so track whether only
+ // indentation or quote markers precede the current offset on its line.
+ let scanned = 0;
+ let lineStartOnly = true;
+ return text.replace(ESCAPED_EQUALS_RUN_RE, (run: string, offset: number, whole: string) => {
+ for (; scanned < offset; scanned += 1) {
+ const char = whole[scanned]!;
+ if (char === "\n") lineStartOnly = true;
+ // The exporter writes a leading `>` as `\>`; it is still a quote marker.
+ else if (lineStartOnly && char === "\\" && whole[scanned + 1] === ">") scanned += 1;
+ else if (char !== ">" && !/\s/u.test(char)) lineStartOnly = false;
+ }
+ const atLineStart = lineStartOnly;
+ scanned = offset + run.length;
+ lineStartOnly = false;
+ if (isEscaped(whole, offset)) return run;
+ // At the start of a line (after indentation or quote markers) a run of `=`
+ // can underline the previous line as a heading, so its escape stays. This
+ // includes the start of a prose chunk, whose preceding text is unknown.
+ if (atLineStart) return run;
+ const length = run.length / 2;
+ const before = whole[offset - 1];
+ const after = whole[offset + run.length];
+ // A neighbouring literal `=` would join this run into a different length.
+ if (before === "=" || after === "=") return run;
+ // A chunk edge borders a code span or block, not whitespace, so it never
+ // counts as space around a two-run.
+ const spaced = (char: string | undefined) => char !== undefined && /\s/u.test(char);
+ if (length === 2 && !(spaced(before) && spaced(after))) return run;
+ return "=".repeat(length);
+ });
+}
+
+export function unescapeEqualsSigns(markdown: string): string {
+ if (!markdown.includes("\\=")) return markdown;
+ return rewriteMarkdownProse(markdown, unescapeProse);
+}
diff --git a/ui/src/lib/underscore-markdown.ts b/ui/src/lib/underscore-markdown.ts
index 351eafbdd74..613f144d233 100644
--- a/ui/src/lib/underscore-markdown.ts
+++ b/ui/src/lib/underscore-markdown.ts
@@ -26,7 +26,7 @@ interface PositionedNode {
}
/** True when the character at `index` is escaped by an odd number of backslashes. */
-function isEscaped(text: string, index: number): boolean {
+export function isEscaped(text: string, index: number): boolean {
let count = 0;
for (let i = index - 1; i >= 0 && text[i] === "\\"; i -= 1) count += 1;
return count % 2 === 1;
@@ -50,8 +50,11 @@ function codeRanges(node: PositionedNode, ranges: Array<[number, number]>): Arra
return ranges;
}
-export function unescapeIntrawordUnderscores(markdown: string): string {
- if (!markdown.includes("\\_")) return markdown;
+/**
+ * Apply `rewrite` to prose only: code spans, code blocks and raw HTML stay
+ * byte-identical. Text the parser cannot place is returned unchanged.
+ */
+export function rewriteMarkdownProse(markdown: string, rewrite: (prose: string) => string): string {
let ranges: Array<[number, number]>;
try {
ranges = codeRanges(fromMarkdown(markdown) as PositionedNode, []);
@@ -63,8 +66,13 @@ export function unescapeIntrawordUnderscores(markdown: string): string {
let cursor = 0;
for (const [start, end] of ranges) {
if (start < cursor) continue;
- result += unescapeProse(markdown.slice(cursor, start)) + markdown.slice(start, end);
+ result += rewrite(markdown.slice(cursor, start)) + markdown.slice(start, end);
cursor = end;
}
- return result + unescapeProse(markdown.slice(cursor));
+ return result + rewrite(markdown.slice(cursor));
+}
+
+export function unescapeIntrawordUnderscores(markdown: string): string {
+ if (!markdown.includes("\\_")) return markdown;
+ return rewriteMarkdownProse(markdown, unescapeProse);
}