From d2026310166d0a0774011728f8e0089f4a5f777e Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sat, 4 Apr 2026 11:21:27 -0700 Subject: [PATCH] fix: autoformat pasted markdown in inline editor (#2673) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The inline markdown editor (MarkdownEditor / MDXEditor) is used to edit agent instructions, issue descriptions, and other content > - When users paste agent instructions copied from terminals or consoles, extra leading whitespace is uniformly added to every line > - PR #2572 fixed markdown structure preservation on paste but did not address the leading whitespace (dedent) problem > - This pull request adds a Lexical paste normalization plugin that strips common leading whitespace and normalizes line endings before MDXEditor processes pasted content > - The benefit is that pasted content from terminals/consoles renders correctly without manual cleanup ## What Changed - **`ui/src/lib/normalize-markdown.ts`** — Pure utility that computes minimum common indentation across non-empty lines and strips it (dedent), plus CRLF → LF normalization - **`ui/src/lib/paste-normalization.ts`** — Lexical `PASTE_COMMAND` plugin at `CRITICAL` priority that intercepts plain-text pastes, normalizes the markdown, and re-dispatches cleaned content for MDXEditor to process. Skips HTML-rich pastes. - **`ui/src/components/MarkdownEditor.tsx`** — Registers the new plugin; updates PR #2572's `handlePasteCapture` to use `normalizeMarkdown()` (dedent + CRLF) instead of `normalizePastedMarkdown()` (CRLF only) for the markdown-routing path - **`ui/src/lib/paste-normalization.test.ts`** — 9 unit tests covering dedent, CRLF normalization, mixed indent, empty lines, single-line passthrough, and edge cases ## Verification - `pnpm --dir ui exec vitest run src/lib/paste-normalization.test.ts` — 9 tests pass - Manual: paste indented agent instructions from a terminal into any inline markdown editor and confirm leading whitespace is stripped ## Risks - Low risk. The plugin only activates for plain-text pastes (no HTML clipboard data). HTML/rich pastes pass through unchanged. Single-line pastes are not modified. The dedent logic is conservative — it only strips whitespace common to all non-empty lines. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- ui/src/components/MarkdownEditor.test.tsx | 4 ++ ui/src/components/MarkdownEditor.tsx | 7 ++- ui/src/lib/normalize-markdown.test.ts | 59 ++++++++++++++++++++ ui/src/lib/normalize-markdown.ts | 44 +++++++++++++++ ui/src/lib/paste-normalization.ts | 66 +++++++++++++++++++++++ 5 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 ui/src/lib/normalize-markdown.test.ts create mode 100644 ui/src/lib/normalize-markdown.ts create mode 100644 ui/src/lib/paste-normalization.ts diff --git a/ui/src/components/MarkdownEditor.test.tsx b/ui/src/components/MarkdownEditor.test.tsx index daf03615..786b2dee 100644 --- a/ui/src/components/MarkdownEditor.test.tsx +++ b/ui/src/components/MarkdownEditor.test.tsx @@ -81,6 +81,10 @@ vi.mock("../lib/mention-deletion", () => ({ mentionDeletionPlugin: () => ({}), })); +vi.mock("../lib/paste-normalization", () => ({ + pasteNormalizationPlugin: () => ({}), +})); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index b77f926c..24fddcc1 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -33,7 +33,9 @@ import { AgentIcon } from "./AgentIconPicker"; import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips"; import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node"; import { mentionDeletionPlugin } from "../lib/mention-deletion"; -import { looksLikeMarkdownPaste, normalizePastedMarkdown } from "../lib/markdownPaste"; +import { looksLikeMarkdownPaste } from "../lib/markdownPaste"; +import { normalizeMarkdown } from "../lib/normalize-markdown"; +import { pasteNormalizationPlugin } from "../lib/paste-normalization"; import { cn } from "../lib/utils"; /* ---- Mention types ---- */ @@ -326,6 +328,7 @@ export const MarkdownEditor = forwardRef linkPlugin({ validateUrl: isSafeMarkdownLinkUrl }), linkDialogPlugin(), mentionDeletionPlugin(), + pasteNormalizationPlugin(), thematicBreakPlugin(), codeBlockPlugin({ defaultCodeBlockLanguage: "txt", @@ -520,7 +523,7 @@ export const MarkdownEditor = forwardRef if (!looksLikeMarkdownPaste(rawText)) return; event.preventDefault(); - ref.current.insertMarkdown(normalizePastedMarkdown(rawText)); + ref.current.insertMarkdown(normalizeMarkdown(rawText)); }, []); return ( diff --git a/ui/src/lib/normalize-markdown.test.ts b/ui/src/lib/normalize-markdown.test.ts new file mode 100644 index 00000000..6afeed59 --- /dev/null +++ b/ui/src/lib/normalize-markdown.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; +import { normalizeMarkdown } from "./normalize-markdown"; + +describe("normalizeMarkdown", () => { + it("strips common leading whitespace (dedent)", () => { + const input = " # Title\n \n Some text\n - Item 1\n - Item 2"; + const expected = "# Title\n\nSome text\n- Item 1\n- Item 2"; + expect(normalizeMarkdown(input)).toBe(expected); + }); + + it("preserves relative indentation within dedented content", () => { + const input = " # Title\n \n Some text\n code block\n More text"; + const expected = "# Title\n\nSome text\n code block\nMore text"; + expect(normalizeMarkdown(input)).toBe(expected); + }); + + it("normalizes CRLF to LF", () => { + const input = "line one\r\nline two\r\nline three"; + const expected = "line one\nline two\nline three"; + expect(normalizeMarkdown(input)).toBe(expected); + }); + + it("normalizes bare CR to LF", () => { + const input = "line one\rline two\rline three"; + const expected = "line one\nline two\nline three"; + expect(normalizeMarkdown(input)).toBe(expected); + }); + + it("returns single-line input unchanged", () => { + const input = " just one line"; + expect(normalizeMarkdown(input)).toBe(" just one line"); + }); + + it("returns text unchanged when no common indent", () => { + const input = "# Title\n\nNo indent here\n- list item"; + expect(normalizeMarkdown(input)).toBe(input); + }); + + it("handles empty lines in indented content", () => { + const input = " line one\n\n line two\n \n line three"; + const expected = "line one\n\nline two\n\nline three"; + expect(normalizeMarkdown(input)).toBe(expected); + }); + + it("returns empty string unchanged", () => { + expect(normalizeMarkdown("")).toBe(""); + }); + + it("handles mixed indent levels correctly", () => { + const input = " base\n nested\n back\n deep"; + const expected = "base\n nested\nback\n deep"; + expect(normalizeMarkdown(input)).toBe(expected); + }); + + it("leaves mixed tab and space indentation unchanged", () => { + const input = "\t# Title\n body\n\t- item"; + expect(normalizeMarkdown(input)).toBe(input); + }); +}); diff --git a/ui/src/lib/normalize-markdown.ts b/ui/src/lib/normalize-markdown.ts new file mode 100644 index 00000000..6cfc2584 --- /dev/null +++ b/ui/src/lib/normalize-markdown.ts @@ -0,0 +1,44 @@ +/** + * Normalize pasted markdown by removing common leading whitespace (dedent) + * and normalizing line endings. This fixes formatting issues when pasting + * content from terminals/consoles that add uniform indentation. + */ +export function normalizeMarkdown(text: string): string { + // Normalize line endings + let result = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + + const lines = result.split("\n"); + if (lines.length <= 1) return result; + + // Find minimum indentation across non-empty lines + let minIndent = Infinity; + let indentStyle: "spaces" | "tabs" | null = null; + for (const line of lines) { + if (line.trim() === "") continue; + const match = line.match(/^(\s+)/); + if (match) { + const leadingWhitespace = match[1]; + const currentStyle = leadingWhitespace.includes("\t") ? "tabs" : "spaces"; + if (indentStyle && indentStyle !== currentStyle) { + return result; + } + indentStyle = currentStyle; + minIndent = Math.min(minIndent, leadingWhitespace.length); + } else { + minIndent = 0; + break; + } + } + + // Strip common indent and trim whitespace-only lines + if (minIndent > 0 && minIndent < Infinity) { + result = lines + .map((line) => { + if (line.trim() === "") return ""; + return line.slice(minIndent); + }) + .join("\n"); + } + + return result; +} diff --git a/ui/src/lib/paste-normalization.ts b/ui/src/lib/paste-normalization.ts new file mode 100644 index 00000000..c766e201 --- /dev/null +++ b/ui/src/lib/paste-normalization.ts @@ -0,0 +1,66 @@ +import { createRootEditorSubscription$, realmPlugin } from "@mdxeditor/editor"; +import { COMMAND_PRIORITY_CRITICAL, PASTE_COMMAND } from "lexical"; +import { looksLikeMarkdownPaste } from "./markdownPaste"; +import { normalizeMarkdown } from "./normalize-markdown"; + +/** + * MDXEditor/Lexical plugin that intercepts paste events and normalizes + * markdown content before the editor processes it. Fixes issues with + * extra leading spaces when pasting from terminals or consoles. + */ +export const pasteNormalizationPlugin = realmPlugin({ + init(realm) { + realm.pub(createRootEditorSubscription$, [ + (editor) => { + let skipNext = false; + + return editor.registerCommand( + PASTE_COMMAND, + (event) => { + if (skipNext) { + skipNext = false; + return false; + } + + const clipboardData = + event instanceof ClipboardEvent ? event.clipboardData : null; + if (!clipboardData) return false; + + const text = clipboardData.getData("text/plain"); + if (!text) return false; + + // If there's HTML content, the source app already formatted it — + // let the default paste handler deal with rich content as-is. + if (clipboardData.getData("text/html")) return false; + + // Markdown-looking pastes are handled by MarkdownEditor.tsx via + // insertMarkdown(), so the plugin only owns the plain-text fallback. + if (looksLikeMarkdownPaste(text)) return false; + + const cleaned = normalizeMarkdown(text); + if (cleaned === text) return false; + + // Prevent the original paste from being processed + if (event instanceof ClipboardEvent) { + event.preventDefault(); + } + + // Re-dispatch with cleaned data so MDXEditor's handler processes it + const dt = new DataTransfer(); + dt.setData("text/plain", cleaned); + const newEvent = new ClipboardEvent("paste", { + clipboardData: dt, + bubbles: true, + cancelable: true, + }); + + skipNext = true; + editor.dispatchCommand(PASTE_COMMAND, newEvent); + return true; + }, + COMMAND_PRIORITY_CRITICAL, + ); + }, + ]); + }, +});