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); }