diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts index be4d09c547..6c3622e272 100644 --- a/packages/app/src/agent-stream/presentation.test.ts +++ b/packages/app/src/agent-stream/presentation.test.ts @@ -124,6 +124,43 @@ function rows(result: { tail: StreamItem[]; head: StreamItem[] }): StreamItem[] } describe("native prose runs", () => { + it("starts another run before combined paragraphs exceed the render cap", () => { + const first = "a".repeat(20_000); + const second = "b".repeat(20_000); + const present = createStreamPresentation({ markdown: "prose-runs" }); + let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] }; + for (const text of [first, "\n\n" + second.slice(0, 10_000), second.slice(10_000)]) { + state = applyStreamEvent({ ...state, event: assistant(text), timestamp: new Date(1000) }); + present({ ...presentationOptions, ...state, transform: undefined }); + } + const live = present({ ...presentationOptions, ...state, transform: undefined }); + expect([...live.tail, ...live.head]).toMatchObject([ + { id: "message-1:run:0", text: first }, + { id: "message-1:run:1", text: second }, + ]); + const source = hydrateStreamState([ + { event: assistant(`${first}\n\n${second}`), timestamp: new Date(1000) }, + ]); + expect( + present({ ...presentationOptions, tail: source, head: [], transform: undefined }).tail, + ).toMatchObject([{ text: first }, { text: second }]); + }); + + it.each([ + "![Screenshot][pic]\n\n[pic]: shots/app.png", + "- parent\n - before ![x](shots/app.png) after\n - next", + ])("keeps image context in Paseo's block renderer: %s", (text) => { + const source = hydrateStreamState([{ event: assistant(text), timestamp: new Date(1000) }]); + const result = createStreamPresentation({ markdown: "prose-runs" })({ + ...presentationOptions, + tail: source, + head: [], + transform: undefined, + }); + expect(result.tail).toMatchObject([{ text, markdownKind: "block" }]); + expect(result.tail).toHaveLength(1); + }); + it("splits inline workspace images out of prose without mistaking code for images", () => { const source = hydrateStreamState([ { diff --git a/packages/app/src/agent-stream/presentation.ts b/packages/app/src/agent-stream/presentation.ts index ed2346f874..4b95647cb3 100644 --- a/packages/app/src/agent-stream/presentation.ts +++ b/packages/app/src/agent-stream/presentation.ts @@ -9,7 +9,7 @@ import { type ToolCallDetailLevel, } from "@/tool-calls/detail-level/projection"; -import { groupMarkdownProse } from "@/components/markdown-prose/runs"; +import { groupMarkdownProse } from "@/components/markdown-prose"; interface PresentationInput { tail: StreamItem[]; diff --git a/packages/app/src/components/markdown-prose/index.tsx b/packages/app/src/components/markdown-prose/index.tsx index 91eed163d9..94ceb0d13a 100644 --- a/packages/app/src/components/markdown-prose/index.tsx +++ b/packages/app/src/components/markdown-prose/index.tsx @@ -1,2 +1,2 @@ -// TypeScript's platform-neutral entry; Metro chooses the native or web implementation. -export { MarkdownProse } from "./index.web"; +export { MarkdownProse } from "./renderer"; +export { groupMarkdownProse } from "./runs"; diff --git a/packages/app/src/components/markdown-prose/prepare.test.ts b/packages/app/src/components/markdown-prose/prepare.test.ts index 6024d5b36e..96da775850 100644 --- a/packages/app/src/components/markdown-prose/prepare.test.ts +++ b/packages/app/src/components/markdown-prose/prepare.test.ts @@ -9,6 +9,8 @@ describe("native prose dialect", () => { it.each([ "Literal & and ©", "# #", + "Setext heading\n==============", + 'A [titled link](https://example.com "A title")', "- first\n\tsecond", "Blank ` ` code and ` normal ` code", "# Heading\n\n- **First**\n- Second\n\n~~Deleted~~", @@ -77,3 +79,25 @@ it("uppercases heading-six text without changing inline code or link destination '
SMALL BOLD lower.ts LINK
\n', ); }); + +it.each([false, true])( + "preserves inline semantics and leaves reference definitions untouched (streaming=%s)", + (streaming) => { + const text = + "[foo][ref] & \\* **bold** \n[linked `code`](https://example.net)\n\n[ref]: https://example.com"; + const prepared = prepareProse(text, noFiles, streaming); + expect(prepared).toContain("[ref]: https://example.com"); + expect(parser.render(prepared)).toBe(parser.render(text)); + }, +); + +it.each(["- [ ] Task", "- [x] Done", "1. [X] Done", "- parent\n - [ ] Nested task"])( + "keeps task markers literal through streaming and settlement: %s", + (text) => { + const settled = prepareProse(text, noFiles); + const streaming = prepareProse(text, noFiles, true); + expect(settled).toMatch(/\\\[/); + expect(streaming).toBe(settled); + expect(parser.render(settled)).toBe(parser.render(text)); + }, +); diff --git a/packages/app/src/components/markdown-prose/prepare.ts b/packages/app/src/components/markdown-prose/prepare.ts index a8a052a0d3..41132c7054 100644 --- a/packages/app/src/components/markdown-prose/prepare.ts +++ b/packages/app/src/components/markdown-prose/prepare.ts @@ -16,7 +16,7 @@ function escapeText(text: string): string { } function renderInline(tokens: Token[], canResolveCode: (text: string) => boolean): string { - const links: string[] = []; + const links: { href: string; title: string | null }[] = []; return tokens .map((token) => { switch (token.type) { @@ -27,10 +27,15 @@ function renderInline(tokens: Token[], canResolveCode: (text: string) => boolean case "hardbreak": return " \n"; case "link_open": - links.push(token.attrGet("href") ?? ""); + links.push({ href: token.attrGet("href") ?? "", title: token.attrGet("title") }); return "["; case "link_close": { - return `](<${links.pop() ?? ""}>)`; + const link = links.pop()!; + const title = + link.title === null + ? "" + : ` "${link.title.replace(/&/g, "&").replace(/"/g, """)}"`; + return `](<${link.href}>${title})`; } case "code_inline": { const needsPadding = @@ -60,52 +65,22 @@ export function prepareProse( text: string, canResolveCode: (text: string) => boolean, streaming = false, -): string { - text = prepareBlockInlines(text, canResolveCode, streaming); - const state = new parser.inline.State(text, parser, {}, []); - let prepared = ""; - while (state.pos < state.posMax) { - const start = state.pos; - parser.inline.skipToken(state); - const source = text.slice(start, state.pos); - // Escapes and entities already protect their contents from delimiter parsing. - if (source.startsWith("\\") || source.startsWith("&")) { - prepared += source; - continue; - } - const tokens = parser.parseInline(source, {})[0]!.children!; - const containsLinkOrCode = tokens.some( - (token) => token.type === "code_inline" || token.type === "link_open", - ); - prepared += containsLinkOrCode - ? renderInline(tokens, canResolveCode) - : source.replace(/\|/g, "\\|"); - } - return prepared; -} - -function prepareBlockInlines( - text: string, - canResolveCode: (text: string) => boolean, - streaming: boolean, ): string { const tokens = (streaming ? streamingParser : parser).parse(text, {}); - const tail = tokens.findLast((token) => token.nesting !== -1); const lines = text.split("\n"); for (let index = tokens.length - 1; index >= 0; index--) { const token = tokens[index]!; const uppercase = tokens[index - 1]?.type === "heading_open" && tokens[index - 1]?.tag === "h6"; - if ( - token.type !== "inline" || - !token.map || - !token.children || - !(uppercase || (streaming && token === tail)) - ) - continue; - const [start, end] = token.map; + if (token.type !== "inline" || !token.map || !token.children) continue; + const [start] = token.map; const prefixes = token.content.split("\n").map((content, offset) => { const line = lines[start + offset]!; - const position = line.indexOf(content); + const heading = tokens[index - 1]; + const headingPrefix = + heading?.type === "heading_open" && heading.markup.startsWith("#") + ? (line.match(/^(?:\s*>\s*)*\s*#{1,6}\s+/)?.[0].length ?? 0) + : 0; + const position = line.indexOf(content, headingPrefix); return position < 0 ? "" : line.slice(0, position); }); if (uppercase) { @@ -119,7 +94,7 @@ function prepareBlockInlines( const replacement = rendered.map( (line, offset) => `${prefixes[offset] ?? prefixes.at(-1) ?? ""}${line}`, ); - lines.splice(start, end - start, ...replacement); + lines.splice(start, token.content.split("\n").length, ...replacement); } return lines.join("\n"); } diff --git a/packages/app/src/components/markdown-prose/index.native.tsx b/packages/app/src/components/markdown-prose/renderer.native.tsx similarity index 89% rename from packages/app/src/components/markdown-prose/index.native.tsx rename to packages/app/src/components/markdown-prose/renderer.native.tsx index 4c3c41233d..3172b05844 100644 --- a/packages/app/src/components/markdown-prose/index.native.tsx +++ b/packages/app/src/components/markdown-prose/renderer.native.tsx @@ -58,8 +58,17 @@ function proseStyleMapping(theme: Theme): { markdownStyle: MarkdownStyle } { marginBottom: styles.blockquote.marginVertical, }, link: { color: styles.link.color, underline: false }, - code: { ...styles.code_inline, borderColor: "transparent" }, - strong: styles.strong, + code: { + // RCTFont does not resolve Fabric's ui-monospace alias. An empty family + // selects the library's native system-monospace font on iOS. + fontFamily: + styles.code_inline.fontFamily === "ui-monospace" ? "" : styles.code_inline.fontFamily, + fontSize: styles.code_inline.fontSize, + color: styles.code_inline.color, + backgroundColor: styles.code_inline.backgroundColor, + borderColor: "transparent", + }, + strong: { fontWeight: "bold" }, em: styles.em, strikethrough: { color: styles.s.color }, thematicBreak: { diff --git a/packages/app/src/components/markdown-prose/renderer.tsx b/packages/app/src/components/markdown-prose/renderer.tsx new file mode 100644 index 0000000000..546b4be816 --- /dev/null +++ b/packages/app/src/components/markdown-prose/renderer.tsx @@ -0,0 +1 @@ +export { MarkdownProse } from "./renderer.web"; diff --git a/packages/app/src/components/markdown-prose/index.web.tsx b/packages/app/src/components/markdown-prose/renderer.web.tsx similarity index 100% rename from packages/app/src/components/markdown-prose/index.web.tsx rename to packages/app/src/components/markdown-prose/renderer.web.tsx diff --git a/packages/app/src/components/markdown-prose/runs.ts b/packages/app/src/components/markdown-prose/runs.ts index 08de6be718..974ed4fe5f 100644 --- a/packages/app/src/components/markdown-prose/runs.ts +++ b/packages/app/src/components/markdown-prose/runs.ts @@ -1,3 +1,4 @@ +import { ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT } from "@/components/assistant-message-render-limit"; import MarkdownIt from "markdown-it"; import type { AssistantMessageItem } from "@/types/stream"; @@ -7,7 +8,8 @@ const partsByBlock = new WeakMap() function splitBlock(block: AssistantMessageItem): AssistantMessageItem[] { const cached = partsByBlock.get(block); if (cached) return cached; - const tokens = parser.parse(block.text, {}); + const environment: { references?: unknown } = {}; + const tokens = parser.parse(block.text, environment); const preserved = tokens.some( (token) => ["fence", "code_block", "table_open", "html_block"].includes(token.type) || @@ -16,7 +18,14 @@ function splitBlock(block: AssistantMessageItem): AssistantMessageItem[] { const isQuote = tokens.some((token) => token.type === "blockquote_open"); const hasImage = tokens.some((token) => token.children?.some((child) => child.type === "image")); let parts: AssistantMessageItem[]; - if (preserved || (isQuote && hasImage)) { + // Retain the container and its definitions when splitting would lose image context. + if ( + preserved || + (hasImage && + (isQuote || + environment.references || + tokens.some((token) => token.type === "list_item_open"))) + ) { parts = [{ ...block, markdownKind: "block" }]; } else if (hasImage) { parts = splitParagraphImages(block); @@ -56,7 +65,11 @@ export function groupMarkdownProse(blocks: AssistantMessageItem[]): AssistantMes const rows: AssistantMessageItem[] = []; for (const block of blocks.flatMap(splitBlock)) { const previous = rows.at(-1); - if (block.markdownKind === "prose" && previous?.markdownKind === "prose") { + if ( + block.markdownKind === "prose" && + previous?.markdownKind === "prose" && + previous.text.length + 2 + block.text.length <= ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT + ) { previous.text += `\n\n${block.text}`; continue; }