diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 354732ba0d8..b7b2c81194e 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -17,6 +17,7 @@ import { stripNoiseMessages } from './native-chat-noise' import { foldToolMessages, splitNativeChatBlocks } from './native-chat-tool-fold' import { isNearBottom, shouldShowJumpToLatest, type ScrollGeometry } from './native-chat-autoscroll' import { isNativeChatPastedImagePath } from './native-chat-image-paste' +import { escapeNativeChatUserMarkdown } from './native-chat-user-markdown' import { NativeChatToolRun } from './NativeChatToolRun' import { NativeChatCopyButton } from './NativeChatCopyButton' import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming' @@ -174,7 +175,7 @@ function MessageRow({ <> ) + .replace(/<[^>]*>/g, '') + .trim() +} + +describe('escapeNativeChatUserMarkdown', () => { + // The swallow this guards against: without the escape the marker line is a + // CommonMark link reference definition and the whole turn renders empty. + it('keeps a marker line the raw Markdown pipeline drops entirely', () => { + const typed = '[Image #1]: /tmp/a.png' + + expect(renderedText(typed)).toBe('') + expect(renderedText(escapeNativeChatUserMarkdown(typed))).toBe('[Image #1]: /tmp/a.png') + }) + + it('keeps a definition-shaped line that precedes other prose', () => { + const typed = '[Image #1]: /tmp/a.png\n\nwhat do you make of it?' + + expect(renderedText(typed)).toBe('what do you make of it?') + expect(renderedText(escapeNativeChatUserMarkdown(typed))).toBe( + '[Image #1]: /tmp/a.png\nwhat do you make of it?' + ) + }) + + it.each([ + 'keep [Image #1] literal', + '[Image #1] describe this', + 'here is my note\n[Image #1]: /tmp/a.png\nend', + '[Image #1]: /tmp/a.png and more text after', + '```\n[Image #1]: /tmp/a.png\n```', + '- [Image #1]: /tmp/a.png', + 'plain prose with a colon: like this' + ])('renders text the pipeline already showed identically', (typed) => { + expect(renderedText(escapeNativeChatUserMarkdown(typed))).toBe(renderedText(typed)) + }) + + it.each(['keep [Image #1] literal', '```\n[Image #1]: /tmp/a.png\n```'])( + 'leaves lines that cannot open a definition byte-identical', + (typed) => { + expect(escapeNativeChatUserMarkdown(typed)).toBe(typed) + } + ) + + it('escapes only the line-leading bracket, once per line', () => { + expect(escapeNativeChatUserMarkdown('[a]: x\n[b]: y')).toBe('\\[a]: x\n\\[b]: y') + expect(escapeNativeChatUserMarkdown(' [a]: x')).toBe(' \\[a]: x') + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-user-markdown.ts b/src/renderer/src/components/native-chat/native-chat-user-markdown.ts new file mode 100644 index 00000000000..bfc0cc5e718 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-user-markdown.ts @@ -0,0 +1,25 @@ +const LINK_REFERENCE_DEFINITION_LINE = /^( {0,3})\[(?=[^\]\n]*\]:)/ +const FENCE_LINE = /^ {0,3}(?:`{3,}|~{3,})/ + +/** + * A user turn is prose, not authored Markdown. CommonMark reads a line like + * `[Image #1]: /tmp/a.png` as a link reference definition and renders nothing at + * all, so a marker the transcript rules preserve would still vanish on screen. + * Escaping the opening bracket is output-neutral for every other line. + */ +export function escapeNativeChatUserMarkdown(markdown: string): string { + if (!markdown.includes(']:')) { + return markdown + } + let inFence = false + return markdown + .split('\n') + .map((line) => { + if (FENCE_LINE.test(line)) { + inFence = !inFence + return line + } + return inFence ? line : line.replace(LINK_REFERENCE_DEFINITION_LINE, '$1\\[') + }) + .join('\n') +}