diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts
index d4b551d8355..f6e69f00862 100644
--- a/src/renderer/src/components/editor/rich-markdown-extensions.ts
+++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts
@@ -39,6 +39,7 @@ import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-htm
import { RichMarkdownOrderedList } from './rich-markdown-ordered-list'
import { RichMarkdownCodeSpanPadding } from './rich-markdown-code-span-padding'
import { RichMarkdownListItem } from './rich-markdown-list-item'
+import { RichMarkdownProseEntities } from './rich-markdown-prose-entities'
import { RichMarkdownParagraph } from './rich-markdown-paragraph'
import { RichMarkdownInlineMath } from './rich-markdown-inline-math'
import { RichMarkdownCodeBlockLowlight } from './rich-markdown-lowlight'
@@ -255,6 +256,7 @@ export function createRichMarkdownExtensions({
}
}),
RichMarkdownCodeSpanPadding,
+ RichMarkdownProseEntities,
createRichMarkdownAnnotationHighlightExtension()
]
diff --git a/src/renderer/src/components/editor/rich-markdown-prose-entities.test.ts b/src/renderer/src/components/editor/rich-markdown-prose-entities.test.ts
new file mode 100644
index 00000000000..ed19fe95ca7
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-prose-entities.test.ts
@@ -0,0 +1,75 @@
+import { Editor } from '@tiptap/core'
+import { describe, expect, it } from 'vitest'
+import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
+import { createRichMarkdownExtensions } from './rich-markdown-extensions'
+import { encodeProseTextForMarkdown } from './rich-markdown-prose-entities'
+import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
+
+function roundTrip(source: string): string {
+ const codec = createRichMarkdownEditorCodec()
+ const editor = new Editor({
+ element: null,
+ extensions: createRichMarkdownExtensions({ codec }),
+ content: encodeRawMarkdownHtmlForRichEditor(source, codec),
+ contentType: 'markdown'
+ })
+ try {
+ return editor.getMarkdown().trimEnd()
+ } finally {
+ editor.destroy()
+ }
+}
+
+describe('encodeProseTextForMarkdown', () => {
+ it.each([
+ ['a < b && c'],
+ ['Finance > Invoices'],
+ ['F&B revenue'],
+ ['Copyright © 2026'],
+ ['A &MadeUpEntity; here'],
+ ['5 <6 and 7> 8']
+ ])('leaves %j unescaped', (text) => {
+ expect(encodeProseTextForMarkdown(text)).toBe(text)
+ })
+
+ it.each([
+ ['Press x', 'Press <kbd>x</kbd>'],
+ ['A here', 'A <!-- comment --> here']
+ ])('escapes tag openings in %j', (text, expected) => {
+ expect(encodeProseTextForMarkdown(text)).toBe(expected)
+ })
+
+ it('encodes a block-quote marker only in the first column', () => {
+ expect(encodeProseTextForMarkdown('> quoted')).toBe('> quoted')
+ expect(encodeProseTextForMarkdown('Finance > Invoices')).toBe('Finance > Invoices')
+ })
+})
+
+describe('prose entity round trip', () => {
+ it.each([
+ ['a < b && c'],
+ ['Finance > Invoices'],
+ ['F&B revenue'],
+ ['Copyright © 2026'],
+ ['A &MadeUpEntity; here'],
+ ['5 <6 and 7> 8'],
+ ['Press x now.'],
+ ['`a < b && c` stays literal']
+ ])('preserves %j', (source) => {
+ expect(roundTrip(source)).toBe(source)
+ })
+
+ it('preserves prose entities inside a heading and a list item', () => {
+ const source = '# F&B > Revenue\n\n- a < b && c'
+ expect(roundTrip(source)).toBe(source)
+ })
+
+ it('is stable across three cycles', () => {
+ const source = 'Finance > Invoices, F&B, a < b && c, © 2026'
+ let current = source
+ for (let cycle = 0; cycle < 3; cycle += 1) {
+ current = roundTrip(current)
+ }
+ expect(current).toBe(source)
+ })
+})
diff --git a/src/renderer/src/components/editor/rich-markdown-prose-entities.ts b/src/renderer/src/components/editor/rich-markdown-prose-entities.ts
new file mode 100644
index 00000000000..38440f3e353
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-prose-entities.ts
@@ -0,0 +1,60 @@
+import { Extension } from '@tiptap/core'
+
+const TAG_OPENING = /^<(?:[a-zA-Z][a-zA-Z0-9-]*|\/[a-zA-Z][a-zA-Z0-9-]*|!|\?)/
+
+type MarkdownTextEncoder = {
+ encodeTextForMarkdown?: (text: string, node: unknown, parentNode?: unknown) => string
+}
+
+function isMarkdownTextEncoder(value: unknown): value is MarkdownTextEncoder {
+ return typeof value === 'object' && value !== null
+}
+
+function escapeMarkdownSyntax(text: string): string {
+ return text.replace(/([\\`*_[\]~])/g, '\\$1')
+}
+
+/** Keep prose punctuation literal unless its raw form would change Markdown parsing. */
+export function encodeProseTextForMarkdown(text: string): string {
+ let output = ''
+ let lineStart = true
+ for (let index = 0; index < text.length; index += 1) {
+ const character = text[index]
+ if (character === '<' && TAG_OPENING.test(text.slice(index))) {
+ output += '<'
+ } else if (character === '>' && lineStart) {
+ output += '>'
+ } else {
+ output += character
+ }
+ if (character === '\n') {
+ lineStart = true
+ } else if (lineStart && (character === ' ' || character === '\t')) {
+ lineStart = true
+ } else {
+ lineStart = false
+ }
+ }
+ return escapeMarkdownSyntax(output)
+}
+
+/** Restore prose entities after TipTap's serializer has classified code contexts. */
+export const RichMarkdownProseEntities = Extension.create({
+ name: 'richMarkdownProseEntities',
+ priority: 1,
+
+ onBeforeCreate() {
+ const managerValue: unknown = this.editor.markdown
+ if (!isMarkdownTextEncoder(managerValue)) {
+ return
+ }
+ const base = managerValue.encodeTextForMarkdown
+ if (typeof base !== 'function') {
+ return
+ }
+ managerValue.encodeTextForMarkdown = (text, node, parentNode) => {
+ const encoded = base.call(managerValue, text, node, parentNode)
+ return encoded === text ? text : encodeProseTextForMarkdown(text)
+ }
+ }
+})