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  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;
}