diff --git a/src/renderer/src/components/editor/rich-markdown-filesystem-path.test.ts b/src/renderer/src/components/editor/rich-markdown-filesystem-path.test.ts
new file mode 100644
index 00000000000..98865c8b86f
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-filesystem-path.test.ts
@@ -0,0 +1,71 @@
+// @vitest-environment happy-dom
+
+import { describe, expect, it } from 'vitest'
+import {
+ extractTerminalLinkFilesystemPath,
+ isFilesystemPath
+} from './rich-markdown-filesystem-path'
+
+describe('isFilesystemPath', () => {
+ it('recognizes Windows drive, UNC, and POSIX absolute paths', () => {
+ expect(isFilesystemPath('C:\\Users\\me\\repo\\CLAUDE.md')).toBe(true)
+ expect(isFilesystemPath('c:/Users/me/repo/CLAUDE.md')).toBe(true)
+ expect(isFilesystemPath('\\\\server\\share\\CLAUDE.md')).toBe(true)
+ expect(isFilesystemPath('/home/me/repo/CLAUDE.md')).toBe(true)
+ expect(isFilesystemPath(' C:\\Users\\me\\CLAUDE.md ')).toBe(true)
+ })
+
+ it('rejects URLs, prose, and ambiguous fragments', () => {
+ expect(isFilesystemPath('https://example.com/docs')).toBe(false)
+ expect(isFilesystemPath('http://CLAUDE.md')).toBe(false)
+ expect(isFilesystemPath('mailto:me@example.com')).toBe(false)
+ expect(isFilesystemPath('see C:\\Users\\me when ready')).toBe(false)
+ expect(isFilesystemPath('CLAUDE.md')).toBe(false)
+ expect(isFilesystemPath('/')).toBe(false)
+ expect(isFilesystemPath('/foo')).toBe(false)
+ expect(isFilesystemPath('a paragraph\nwith C:\\path')).toBe(false)
+ expect(isFilesystemPath('')).toBe(false)
+ })
+})
+
+describe('extractTerminalLinkFilesystemPath', () => {
+ it('extracts a path from a single terminal-style anchor with a fabricated href', () => {
+ const path = 'C:\\Users\\me\\repo\\CLAUDE.md'
+ expect(
+ extractTerminalLinkFilesystemPath(`${path}`)
+ ).toBe(path)
+ })
+
+ it('extracts a path when the anchor text itself is a path', () => {
+ const path = '/home/me/repo/CLAUDE.md'
+ expect(
+ extractTerminalLinkFilesystemPath(`${path}`)
+ ).toBe(path)
+ })
+
+ it('returns null for a genuine URL anchor', () => {
+ expect(
+ extractTerminalLinkFilesystemPath(
+ 'https://example.com/docs'
+ )
+ ).toBeNull()
+ })
+
+ it('returns null when the anchor is part of larger rich content', () => {
+ expect(
+ extractTerminalLinkFilesystemPath(
+ '
see C:\\repo\\CLAUDE.md please
'
+ )
+ ).toBeNull()
+ })
+
+ it('returns null for multiple anchors or empty html', () => {
+ expect(
+ extractTerminalLinkFilesystemPath(
+ 'C:\\aC:\\b'
+ )
+ ).toBeNull()
+ expect(extractTerminalLinkFilesystemPath('')).toBeNull()
+ expect(extractTerminalLinkFilesystemPath('plain
')).toBeNull()
+ })
+})
diff --git a/src/renderer/src/components/editor/rich-markdown-filesystem-path.ts b/src/renderer/src/components/editor/rich-markdown-filesystem-path.ts
new file mode 100644
index 00000000000..87382fafd61
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-filesystem-path.ts
@@ -0,0 +1,75 @@
+// Why: terminals (e.g. Claude Code) render bare file paths as clickable links
+// by fabricating an `http://` href. Pasting that into the rich
+// editor would let TipTap's Link mark turn `C:\dir\CLAUDE.md` into the broken
+// `[CLAUDE.md](http://CLAUDE.md)` (the directory is lost, the URL is malformed).
+// These detectors let the paste handler keep such content as plain text.
+
+// Windows drive-letter absolute path, e.g. C:\Users\me\CLAUDE.md or C:/Users/me.
+const WINDOWS_DRIVE_PATH = /^[a-zA-Z]:[\\/]/
+// UNC path, e.g. \\server\share\file.
+const WINDOWS_UNC_PATH = /^\\\\[^\\/]/
+// POSIX absolute path with at least one more segment, e.g. /home/me/CLAUDE.md.
+// A lone "/" or a single segment like "/foo" is excluded so we don't capture
+// things that read more like relative web fragments.
+const POSIX_ABSOLUTE_PATH = /^\/[^/\s][^\n]*\/[^\n]*$/
+
+/**
+ * Returns true when `text` is a single filesystem path that the rich editor
+ * should paste verbatim rather than autolink. The text is trimmed first because
+ * terminal selections frequently carry a trailing newline.
+ */
+export function isFilesystemPath(text: string): boolean {
+ const trimmed = text.trim()
+ if (!trimmed) {
+ return false
+ }
+ // Why: multi-line clipboard content is prose, not a path; limiting to a
+ // single line avoids hijacking ordinary paragraph pastes.
+ if (/[\n\r]/.test(trimmed)) {
+ return false
+ }
+ if (WINDOWS_DRIVE_PATH.test(trimmed) || WINDOWS_UNC_PATH.test(trimmed)) {
+ return true
+ }
+ // POSIX paths can contain spaces, so only require the leading-slash shape.
+ return POSIX_ABSOLUTE_PATH.test(trimmed)
+}
+
+/**
+ * When the clipboard HTML is a single terminal-style link wrapping a filesystem
+ * path, returns that path's visible text so it can be pasted as plain text.
+ * Returns null for genuine links or anything more complex than one anchor.
+ */
+export function extractTerminalLinkFilesystemPath(html: string): string | null {
+ if (!html.trim()) {
+ return null
+ }
+ let doc: Document
+ try {
+ doc = new DOMParser().parseFromString(html, 'text/html')
+ } catch {
+ return null
+ }
+ const body = doc.body
+ if (!body) {
+ return null
+ }
+ const anchors = body.querySelectorAll('a[href]')
+ if (anchors.length !== 1) {
+ return null
+ }
+ const anchor = anchors[0]
+ // Why: only treat the anchor as a path link when it is effectively the whole
+ // payload (a lone clickable token), not one link inside a larger rich paste.
+ if ((body.textContent ?? '').trim() !== (anchor.textContent ?? '').trim()) {
+ return null
+ }
+ // Why: terminals (e.g. Claude Code) keep the full path as the anchor's
+ // visible text while fabricating an `http://` href. Recognizing the
+ // path in the visible text catches the broken link regardless of that href.
+ const visibleText = (anchor.textContent ?? '').trim()
+ if (visibleText && isFilesystemPath(visibleText)) {
+ return visibleText
+ }
+ return null
+}
diff --git a/src/renderer/src/components/editor/rich-markdown-paste-handler.test.ts b/src/renderer/src/components/editor/rich-markdown-paste-handler.test.ts
index a915965f9e8..b5fb5b191b3 100644
--- a/src/renderer/src/components/editor/rich-markdown-paste-handler.test.ts
+++ b/src/renderer/src/components/editor/rich-markdown-paste-handler.test.ts
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { handleRichMarkdownImagePaste } from './rich-markdown-paste-image'
+import { handleRichMarkdownFilesystemPathPaste } from './rich-markdown-path-paste'
import { handleRichMarkdownLargeTextPaste } from './rich-markdown-large-text-paste'
import { handleRichMarkdownPaste } from './rich-markdown-paste-handler'
@@ -7,6 +8,10 @@ vi.mock('./rich-markdown-paste-image', () => ({
handleRichMarkdownImagePaste: vi.fn()
}))
+vi.mock('./rich-markdown-path-paste', () => ({
+ handleRichMarkdownFilesystemPathPaste: vi.fn()
+}))
+
vi.mock('./rich-markdown-large-text-paste', () => ({
handleRichMarkdownLargeTextPaste: vi.fn()
}))
@@ -14,6 +19,7 @@ vi.mock('./rich-markdown-large-text-paste', () => ({
describe('rich markdown paste handler', () => {
beforeEach(() => {
vi.mocked(handleRichMarkdownImagePaste).mockReset()
+ vi.mocked(handleRichMarkdownFilesystemPathPaste).mockReset()
vi.mocked(handleRichMarkdownLargeTextPaste).mockReset()
})
@@ -38,11 +44,32 @@ describe('rich markdown paste handler', () => {
worktreeId: 'wt-1',
runtimeEnvironmentId: undefined
})
+ expect(handleRichMarkdownFilesystemPathPaste).not.toHaveBeenCalled()
expect(handleRichMarkdownLargeTextPaste).not.toHaveBeenCalled()
})
- it('falls through to large text handling when image paste declines ownership', () => {
+ it('claims a filesystem path paste before large text fallback', () => {
vi.mocked(handleRichMarkdownImagePaste).mockReturnValue(false)
+ vi.mocked(handleRichMarkdownFilesystemPathPaste).mockReturnValue(true)
+ const editor = {} as never
+ const event = {} as ClipboardEvent
+
+ expect(
+ handleRichMarkdownPaste({
+ editor,
+ event,
+ filePath: '/repo/note.md',
+ worktreeId: 'wt-1'
+ })
+ ).toBe(true)
+
+ expect(handleRichMarkdownFilesystemPathPaste).toHaveBeenCalledWith(editor, event)
+ expect(handleRichMarkdownLargeTextPaste).not.toHaveBeenCalled()
+ })
+
+ it('falls through to large text handling when image and path paste decline ownership', () => {
+ vi.mocked(handleRichMarkdownImagePaste).mockReturnValue(false)
+ vi.mocked(handleRichMarkdownFilesystemPathPaste).mockReturnValue(false)
vi.mocked(handleRichMarkdownLargeTextPaste).mockReturnValue(true)
const editor = {} as never
const event = {} as ClipboardEvent
diff --git a/src/renderer/src/components/editor/rich-markdown-paste-handler.ts b/src/renderer/src/components/editor/rich-markdown-paste-handler.ts
index 63bb6e47081..91354c46994 100644
--- a/src/renderer/src/components/editor/rich-markdown-paste-handler.ts
+++ b/src/renderer/src/components/editor/rich-markdown-paste-handler.ts
@@ -1,5 +1,6 @@
import type { Editor } from '@tiptap/react'
import { handleRichMarkdownImagePaste } from './rich-markdown-paste-image'
+import { handleRichMarkdownFilesystemPathPaste } from './rich-markdown-path-paste'
import { handleRichMarkdownLargeTextPaste } from './rich-markdown-large-text-paste'
export type RichMarkdownPasteHandlerArgs = {
@@ -29,5 +30,11 @@ export function handleRichMarkdownPaste({
return true
}
+ // Why: must run before the Link extension's paste plugins so a bare path is
+ // never autolinked into a broken markdown link (e.g. [CLAUDE.md](http://CLAUDE.md)).
+ if (handleRichMarkdownFilesystemPathPaste(editor, event)) {
+ return true
+ }
+
return handleRichMarkdownLargeTextPaste(editor, event)
}
diff --git a/src/renderer/src/components/editor/rich-markdown-path-paste.test.ts b/src/renderer/src/components/editor/rich-markdown-path-paste.test.ts
new file mode 100644
index 00000000000..50a76bc679c
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-path-paste.test.ts
@@ -0,0 +1,106 @@
+// @vitest-environment happy-dom
+
+import { afterEach, describe, expect, it } from 'vitest'
+import { Editor } from '@tiptap/core'
+import { createRichMarkdownExtensions } from './rich-markdown-extensions'
+import { handleRichMarkdownFilesystemPathPaste } from './rich-markdown-path-paste'
+
+function makeEditor(): Editor {
+ const element = document.createElement('div')
+ document.body.appendChild(element)
+ return new Editor({
+ element,
+ extensions: createRichMarkdownExtensions(),
+ content: '',
+ contentType: 'markdown',
+ editorProps: {
+ // Why: mirror the production wiring so the regression test exercises the
+ // same paste path order (our handler ahead of the Link extension plugins).
+ handlePaste: (_view, event) =>
+ handleRichMarkdownFilesystemPathPaste(editorRef, event as ClipboardEvent)
+ }
+ })
+}
+
+let editorRef: Editor
+
+function makeClipboardEvent(plain: string, html = ''): ClipboardEvent {
+ const data = new DataTransfer()
+ if (plain) {
+ data.setData('text/plain', plain)
+ }
+ if (html) {
+ data.setData('text/html', html)
+ }
+ return new ClipboardEvent('paste', {
+ clipboardData: data,
+ bubbles: true,
+ cancelable: true
+ })
+}
+
+function pasteIntoEditor(editor: Editor, plain: string, html = ''): string {
+ editorRef = editor
+ editor.view.focus()
+ const event = makeClipboardEvent(plain, html)
+ editor.view.dom.dispatchEvent(event)
+ return editor.getMarkdown().trimEnd()
+}
+
+afterEach(() => {
+ document.body.replaceChildren()
+})
+
+describe('rich markdown filesystem path paste (real editor)', () => {
+ it('keeps a pasted Windows absolute path as plain text, not a broken link', () => {
+ const editor = makeEditor()
+ try {
+ const path = 'C:\\Users\\me\\repo\\CLAUDE.md'
+ const markdown = pasteIntoEditor(
+ editor,
+ path,
+ `${path}`
+ )
+ expect(markdown).toBe(path)
+ expect(markdown).not.toContain('http://')
+ expect(markdown).not.toContain('](')
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('keeps a pasted UNC path as plain text', () => {
+ const editor = makeEditor()
+ try {
+ const path = '\\\\server\\share\\notes\\CLAUDE.md'
+ const markdown = pasteIntoEditor(editor, path)
+ expect(markdown).toBe(path)
+ expect(markdown).not.toContain('](')
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('keeps a pasted POSIX absolute path as plain text', () => {
+ const editor = makeEditor()
+ try {
+ const path = '/home/me/repo/CLAUDE.md'
+ const markdown = pasteIntoEditor(editor, path)
+ expect(markdown).toBe(path)
+ expect(markdown).not.toContain('](')
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('still autolinks a genuine https URL', () => {
+ const editor = makeEditor()
+ try {
+ const url = 'https://example.com/docs'
+ const markdown = pasteIntoEditor(editor, url)
+ expect(markdown).toBe(`[${url}](${url})`)
+ } finally {
+ editor.destroy()
+ }
+ })
+})
diff --git a/src/renderer/src/components/editor/rich-markdown-path-paste.ts b/src/renderer/src/components/editor/rich-markdown-path-paste.ts
new file mode 100644
index 00000000000..03064515936
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-path-paste.ts
@@ -0,0 +1,54 @@
+import type { Editor } from '@tiptap/react'
+import {
+ extractTerminalLinkFilesystemPath,
+ isFilesystemPath
+} from './rich-markdown-filesystem-path'
+
+function readPlainText(event: ClipboardEvent): string {
+ return event.clipboardData?.getData('text/plain') ?? ''
+}
+
+function readHtmlText(event: ClipboardEvent): string {
+ return event.clipboardData?.getData('text/html') ?? ''
+}
+
+/**
+ * Resolves the plain-text filesystem path the clipboard is carrying, if any.
+ * The plain-text flavor holds the true path; the HTML flavor is only consulted
+ * to recognize terminal-style `` links.
+ */
+function resolvePastedFilesystemPath(event: ClipboardEvent): string | null {
+ const plainText = readPlainText(event)
+ if (plainText && isFilesystemPath(plainText)) {
+ return plainText.trim()
+ }
+ const html = readHtmlText(event)
+ if (html) {
+ return extractTerminalLinkFilesystemPath(html)
+ }
+ return null
+}
+
+/**
+ * Intercepts pastes of bare filesystem paths so TipTap's Link mark cannot
+ * convert them into a broken markdown link. Inserts the path as plain text and
+ * claims the paste; returns false to let normal handling proceed otherwise.
+ */
+export function handleRichMarkdownFilesystemPathPaste(
+ editor: Editor | null,
+ event: ClipboardEvent
+): boolean {
+ if (event.defaultPrevented || !editor) {
+ return false
+ }
+ const path = resolvePastedFilesystemPath(event)
+ if (!path) {
+ return false
+ }
+
+ event.preventDefault()
+ // Why: insertText writes a literal text node with no link mark, so the path
+ // round-trips through markdown serialization unchanged (no autolinking).
+ editor.view.dispatch(editor.state.tr.insertText(path))
+ return true
+}