diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 9663d158e..bf7519543 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -23,6 +23,7 @@ import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; import { resolveMarkdownFileLinkTarget } from "../markdown-links"; import { readNativeApi } from "../nativeApi"; +import { finalizeStreamingMarkdown } from "../streaming-markdown"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -238,6 +239,10 @@ function SuspenseShikiCodeBlock({ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const diffThemeName = resolveDiffThemeName(resolvedTheme); + const renderedText = useMemo( + () => (isStreaming ? finalizeStreamingMarkdown(text) : text), + [isStreaming, text], + ); const markdownComponents = useMemo( () => ({ a({ node: _node, href, ...props }) { @@ -291,7 +296,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { return (
- {text} + {renderedText}
); diff --git a/apps/web/src/streaming-markdown.test.ts b/apps/web/src/streaming-markdown.test.ts new file mode 100644 index 000000000..d5ecadb08 --- /dev/null +++ b/apps/web/src/streaming-markdown.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import { finalizeStreamingMarkdown } from "./streaming-markdown"; + +describe("finalizeStreamingMarkdown", () => { + it("closes unfinished bold markers while streaming", () => { + expect(finalizeStreamingMarkdown("I just love **bold text")).toBe("I just love **bold text**"); + }); + + it("closes unfinished inline code spans", () => { + expect(finalizeStreamingMarkdown("Use `bun fmt")).toBe("Use `bun fmt`"); + }); + + it("closes unfinished fenced code blocks", () => { + expect(finalizeStreamingMarkdown("```ts\nconst value = 1;")).toBe( + "```ts\nconst value = 1;\n```", + ); + }); + + it("preserves completed markdown without adding extra delimiters", () => { + expect(finalizeStreamingMarkdown("Already **done** and `closed`")).toBe( + "Already **done** and `closed`", + ); + }); + + it("does not treat intraword underscores as emphasis", () => { + expect(finalizeStreamingMarkdown("snake_case and __bold")).toBe("snake_case and __bold__"); + }); + + it("closes nested emphasis in reverse order", () => { + expect(finalizeStreamingMarkdown("**bold *italic")).toBe("**bold *italic***"); + }); + + it("closes unfinished inline markdown links", () => { + expect(finalizeStreamingMarkdown("See [docs](https://example.com/path")).toBe( + "See [docs](https://example.com/path)", + ); + }); + + it("closes nested parentheses inside unfinished link destinations", () => { + expect(finalizeStreamingMarkdown("See [docs](https://example.com/path(foo")).toBe( + "See [docs](https://example.com/path(foo))", + ); + }); + + it("ignores link-like syntax inside inline code", () => { + expect(finalizeStreamingMarkdown("Use `[docs](https://example.com`")).toBe( + "Use `[docs](https://example.com`", + ); + }); + + it("closes unfinished strikethrough markers", () => { + expect(finalizeStreamingMarkdown("This is ~~important")).toBe("This is ~~important~~"); + }); + + it("closes unfinished autolinks", () => { + expect(finalizeStreamingMarkdown("Visit ", + ); + }); + + it("does not keep autolinks open across whitespace", () => { + expect(finalizeStreamingMarkdown("Visit ") { + openAutolink = false; + } else if (isWhitespace(char) || char === "<") { + openAutolink = false; + } + index += 1; + continue; + } + + if (char === "<" && /^(https?:\/\/|mailto:)/i.test(line.slice(index + 1))) { + openAutolink = true; + index += 1; + continue; + } + + if (char === "[") { + openLinkLabelDepth += 1; + index += 1; + continue; + } + + if (char === "]") { + if (openLinkLabelDepth > 0 && line[index + 1] === "(") { + openLinkLabelDepth -= 1; + openLinkDestinationDepths.push(0); + index += 2; + continue; + } + + if (openLinkLabelDepth > 0) { + openLinkLabelDepth -= 1; + } + + index += 1; + continue; + } + + if (char === "(" && openLinkDestinationDepths.length > 0) { + const lastIndex = openLinkDestinationDepths.length - 1; + const currentDepth = openLinkDestinationDepths[lastIndex]; + if (currentDepth != null) { + openLinkDestinationDepths[lastIndex] = currentDepth + 1; + } + index += 1; + continue; + } + + if (char === ")" && openLinkDestinationDepths.length > 0) { + const lastIndex = openLinkDestinationDepths.length - 1; + const currentDepth = openLinkDestinationDepths[lastIndex]; + if (currentDepth === 0) { + openLinkDestinationDepths.pop(); + } else if (currentDepth != null) { + openLinkDestinationDepths[lastIndex] = currentDepth - 1; + } + index += 1; + continue; + } + + if (char === "~" && line[index + 1] === "~") { + let endIndex = index + 2; + while (line[endIndex] === "~") { + endIndex += 1; + } + + const pairCount = Math.floor((endIndex - index) / 2); + for (let pairIndex = 0; pairIndex < pairCount; pairIndex += 1) { + const topDelimiter = emphasisStack[emphasisStack.length - 1]; + if (topDelimiter === "~~") { + emphasisStack.pop(); + } else { + emphasisStack.push("~~"); + } + } + + index += pairCount * 2; + continue; + } + } + + if (activeInlineCodeDelimiter == null && (char === "*" || char === "_")) { + let endIndex = index + 1; + while (line[endIndex] === char) { + endIndex += 1; + } + + const delimiter = line.slice(index, endIndex); + const previousChar = index > 0 ? line[index - 1] : undefined; + const nextChar = line[endIndex]; + const canOpen = !isWhitespace(nextChar); + const canClose = !isWhitespace(previousChar); + const isIntrawordUnderscore = + char === "_" && isWordChar(previousChar) && isWordChar(nextChar); + + if (!isIntrawordUnderscore) { + const topDelimiter = emphasisStack[emphasisStack.length - 1]; + if (canClose && topDelimiter === delimiter) { + emphasisStack.pop(); + } else if (canOpen) { + emphasisStack.push(delimiter); + } + } + + index = endIndex; + continue; + } + + index += 1; + } + + return { + inlineCodeDelimiter: activeInlineCodeDelimiter, + openLinkLabelDepth, + openLinkDestinationDepths, + openAutolink, + }; +} + +export function finalizeStreamingMarkdown(text: string): string { + if (text.length === 0) { + return text; + } + + const lines = text.split("\n"); + const emphasisStack: string[] = []; + let inlineState: InlineMarkdownScanState = { + inlineCodeDelimiter: null, + openLinkLabelDepth: 0, + openLinkDestinationDepths: [], + openAutolink: false, + }; + let openFence: { marker: "`" | "~"; length: number } | null = null; + + for (const line of lines) { + if (openFence != null) { + const closingMatch = line.match(FENCE_CLOSE_PATTERN); + if (closingMatch) { + const closingDelimiter = closingMatch[1] ?? ""; + if ( + closingDelimiter.startsWith(openFence.marker) && + closingDelimiter.length >= openFence.length + ) { + openFence = null; + } + } + continue; + } + + const openingMatch = line.match(FENCE_OPEN_PATTERN); + if (openingMatch) { + const delimiter = openingMatch[1] ?? ""; + const marker = delimiter[0]; + if (marker === "`" || marker === "~") { + openFence = { marker, length: delimiter.length }; + inlineState = { + inlineCodeDelimiter: null, + openLinkLabelDepth: 0, + openLinkDestinationDepths: [], + openAutolink: false, + }; + } + continue; + } + + inlineState = scanInlineMarkdown(line, emphasisStack, inlineState); + } + + let suffix = ""; + if (openFence != null) { + suffix += `${text.endsWith("\n") ? "" : "\n"}${openFence.marker.repeat(openFence.length)}`; + } + if (inlineState.inlineCodeDelimiter != null) { + suffix += "`".repeat(inlineState.inlineCodeDelimiter); + } + if (inlineState.openLinkDestinationDepths.length > 0) { + suffix += inlineState.openLinkDestinationDepths.map((depth) => ")".repeat(depth + 1)).join(""); + } + if (inlineState.openAutolink) { + suffix += ">"; + } + if (emphasisStack.length > 0) { + suffix += emphasisStack.toReversed().join(""); + } + + return `${text}${suffix}`; +}