/**
* Response — a model's answer, rendered as it arrives.
*
* An answer is markdown. It has headings and lists and fenced code in it,
* because that is what a model writes, and rendering it as one run of plain
* text throws away the structure the model went to the trouble of producing.
*
* So this reads the markdown and renders it through the library's own parts —
* `Typography` for prose, `CodeBlock` for fences, `Table` for tables. Nothing
* here draws its own type or its own colours: an answer inside a message bubble
* should look like the app it is in, not like a document viewer someone
* embedded.
*
* ```tsx
* {text}
* ```
*
* ## Why it is a whole component and not a ``
*
* Because the text is still arriving, and that changes everything about how it
* has to be read. A token stream hands you every prefix of the final answer, so
* a renderer sees `**bo`, then `**bol`, then `**bold**` — three documents, two
* of which have literal asterisks in them. Render each faithfully and the
* answer flickers between styles on nearly every frame, which is worse than no
* formatting at all: the eye tracks the flicker instead of the words.
*
* `isStreaming` tells the reader to finish an unterminated construct at the end
* of the input rather than escaping it — an open fence is a code block that is
* still filling, an open `**` is bold text still being written. The rule it
* works to is that no word already on screen may disappear when the next token
* arrives; delimiters may vanish as they are recognised, words never do.
*
* ## Where the props come from
*
* With the AI SDK, `children` is the text of the assistant message's text parts
* joined together, and `isStreaming` is `status === 'streaming'`. Join the
* parts — one `Response` per part renders a heading in one component and the
* paragraph under it in another, and neither knows about the other.
*/
import { memo, useMemo, type ComponentType, type ReactNode } from 'react';
import { Linking, View, type ViewProps } from 'react-native';
import { Text } from '../../primitives/text';
import { cn } from '../../utils/cn';
import { CodeBlock } from '../code-block';
import { Table } from '../table';
import { Typography } from '../typography';
import {
parseMarkdown,
type Align,
type Block,
type InlineToken,
} from './markdown';
export type { Block as ResponseBlock, InlineToken as ResponseInline } from './markdown';
/** Heading levels below h4 are h4's size; a chat answer has no use for six. */
const HEADING_TYPE = ['h2', 'h3', 'h4', 'h4', 'h4', 'h4'] as const;
/** Schemes a link may open with. Anything else is rendered but not pressable. */
const DEFAULT_LINK_PREFIXES = ['https://', 'http://', 'mailto:', 'tel:'];
export interface ResponseComponents {
/** Replaces the whole code block — for a runnable snippet, or a diff viewer. */
code?: ComponentType<{ code: string; language?: string; streaming: boolean }>;
/** Replaces an image. Nothing is rendered for one by default. */
image?: ComponentType<{ src: string; alt: string }>;
}
export interface ResponseProps extends Omit {
className?: string;
/** The markdown. */
children?: string;
/**
* Whether more is still coming. Finishes an unterminated construct at the end
* of the text instead of escaping it, so the answer does not flicker between
* styles as its delimiters arrive.
*/
isStreaming?: boolean;
/** Turns off speculative completion entirely, even while streaming. */
parseIncompleteMarkdown?: boolean;
/** What a link does. Opens it with the system handler by default. */
onLinkPress?: (href: string) => void;
/**
* Schemes a link is allowed to open. A model can write any URL it likes, and
* an answer is not a trusted document — so the default list is the four that
* cannot do anything but navigate.
*/
allowedLinkPrefixes?: string[];
/** Swap out how a block is drawn. */
components?: ResponseComponents;
/** Line numbers in fenced code. */
showLineNumbers?: boolean;
}
function ResponseRoot({
className,
children = '',
isStreaming = false,
parseIncompleteMarkdown = true,
onLinkPress,
allowedLinkPrefixes = DEFAULT_LINK_PREFIXES,
components,
showLineNumbers = false,
...props
}: ResponseProps) {
const speculate = isStreaming && parseIncompleteMarkdown;
const blocks = useMemo(
() => parseMarkdown(children, speculate),
[children, speculate]
);
const context: RenderContext = {
streaming: isStreaming,
onLinkPress,
allowedLinkPrefixes,
components,
showLineNumbers,
};
return (
{blocks.map((block, index) => (
))}
);
}
/**
* Re-renders only when the text changes.
*
* A stream re-renders its parent on every token, and everything else the parent
* hands down — the callbacks, the overrides — is usually a fresh object each
* time. Comparing the one prop that actually decides the output keeps a long
* answer from re-parsing because a sibling moved.
*/
export const Response = memo(
ResponseRoot,
(previous, next) =>
previous.children === next.children &&
previous.isStreaming === next.isStreaming &&
previous.className === next.className
);
Response.displayName = 'Response';
/* -------------------------------------------------------------------------- */
/* Blocks */
/* -------------------------------------------------------------------------- */
interface RenderContext {
streaming: boolean;
onLinkPress?: (href: string) => void;
allowedLinkPrefixes: string[];
components?: ResponseComponents;
showLineNumbers: boolean;
}
function BlockView({ block, context }: { block: Block; context: RenderContext }) {
switch (block.type) {
case 'heading':
return (
{spans(block.inline, context)}
);
case 'paragraph':
return (
{spans(block.inline, context)}
);
case 'code': {
const Custom = context.components?.code;
if (Custom) {
return (
);
}
return (
{block.language ? (
{block.language}
{/*
No copy button while the fence is still open: copying half a
snippet is worse than not offering to, because it succeeds.
*/}
{block.open && context.streaming ? null : }
) : null}
);
}
case 'quote':
// The rule is drawn here rather than by `Typography.Blockquote`, which
// puts its children inside a `Text` — right for a quoted sentence, wrong
// for a quote that turns out to contain a list and a snippet. `border-s`
// so the rule moves to the other side under a right-to-left `Direction`.
return (
{block.blocks.map((child, index) => (
))}
);
case 'list':
return (
{block.items.map((item, index) => (
{item.map((child, position) => (
))}
))}
);
case 'table':
return ;
case 'rule':
return ;
}
}
const ALIGNMENT: Record = {
left: 'start',
center: 'center',
right: 'end',
};
/**
* A GFM table.
*
* Every column gets the same share of the width. A markdown table carries no
* widths, and guessing them from the longest cell makes the columns jump about
* as rows stream in — which is exactly the movement everything else here is
* built to avoid.
*/
function TableBlock({
block,
context,
}: {
block: Extract;
context: RenderContext;
}) {
const align = (index: number) => ALIGNMENT[block.align[index] ?? 'left'];
return (
{block.head.map((cell, index) => (
))}
{block.rows.map((row, rowIndex) => (
{block.head.map((_unused, index) => (
))}
))}
);
}
/* -------------------------------------------------------------------------- */
/* Inline */
/* -------------------------------------------------------------------------- */
/**
* A run of styled spans, inside a `Text` of its own.
*
* Nested `Text` rather than a row of views, because only nested text wraps as
* one paragraph — a bolded word in a view would break the line before it and
* leave a gap where the sentence should have continued.
*
* The wrapper is not optional. Half these spans are bare strings, and a bare
* string is only legal inside a `Text`: dropped into a `Table.Cell` — which is
* a view — React Native refuses to render it at all.
*/
function InlineRun({
tokens,
context,
className,
}: {
tokens: InlineToken[];
context: RenderContext;
className?: string;
}) {
return {spans(tokens, context)};
}
function spans(tokens: InlineToken[], context: RenderContext): ReactNode {
return tokens.map((token, index) => {
if (token.kind === 'image') {
const Custom = context.components?.image;
// Nothing by default. An image in an answer is a URL the model wrote, and
// fetching it unasked is a request to a host nobody chose.
return Custom ? : null;
}
if (token.kind === 'code') {
// A `Text`, not `Typography.Code` — that one is a view around a text, and
// a view inside a paragraph breaks the line before it and leaves a gap
// where the sentence should have carried on. A background and a
// monospace face are what make it read as code; they survive nesting,
// and padding does not.
//
// No relative font size either. There is no `em` here to be relative to:
// a value in one sends the style resolver looking for a parent size that
// is itself expressed in `em`, and it does not come back. The inherited
// size is the right one anyway — inline code is part of its sentence.
return (
{token.value}
);
}
if (token.kind === 'link') {
const href = token.href ?? '';
const openable = context.allowedLinkPrefixes.some((prefix) => href.startsWith(prefix));
return (
(context.onLinkPress ?? defaultOpen)(href)
: undefined
}
>
{token.value}
);
}
if (!token.bold && !token.italic && !token.strike) return token.value;
return (
{token.value}
);
});
}
function marks(token: InlineToken): string {
return cn(
token.bold && 'font-semibold',
token.italic && 'italic',
token.strike && 'line-through'
);
}
function defaultOpen(href: string) {
// Fire and forget: a link the platform declines to open is not worth an
// unhandled rejection, and there is nothing useful to do about one.
void Linking.openURL(href).catch(() => {});
}