@vvedantb/ui 0.17.1 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +251 -3
  2. package/package.json +13 -5
  3. package/shell/src/SidebarResize.tsx +1 -4
  4. package/src/ai-elements/__snapshots__/queue.test.tsx.snap +11 -0
  5. package/src/ai-elements/activity-question-utils.ts +26 -0
  6. package/src/ai-elements/activity-question.tsx +159 -0
  7. package/src/ai-elements/activity-shared.tsx +459 -0
  8. package/src/ai-elements/activity-step-detail.tsx +115 -0
  9. package/src/ai-elements/activity-step-label.ts +981 -0
  10. package/src/ai-elements/activity-tasks-utils.ts +199 -0
  11. package/src/ai-elements/activity-tasks.tsx +770 -0
  12. package/src/ai-elements/artifact.tsx +162 -52
  13. package/src/ai-elements/attachments.tsx +62 -32
  14. package/src/ai-elements/code-block.tsx +76 -22
  15. package/src/ai-elements/context.tsx +370 -0
  16. package/src/ai-elements/conversation.tsx +186 -62
  17. package/src/ai-elements/file-chip.tsx +53 -0
  18. package/src/ai-elements/message.tsx +161 -50
  19. package/src/ai-elements/model-picker-content.tsx +505 -0
  20. package/src/ai-elements/model-picker-types.ts +56 -0
  21. package/src/ai-elements/model-picker.tsx +301 -0
  22. package/src/ai-elements/model-selector.tsx +102 -21
  23. package/src/ai-elements/plan.tsx +130 -0
  24. package/src/ai-elements/prompt-input-settings.tsx +144 -0
  25. package/src/ai-elements/prompt-input-speech.tsx +189 -0
  26. package/src/ai-elements/prompt-input-traits.tsx +281 -0
  27. package/src/ai-elements/prompt-input.tsx +320 -134
  28. package/src/ai-elements/provider-icon.tsx +133 -0
  29. package/src/ai-elements/queue.tsx +293 -9
  30. package/src/ai-elements/shimmer.tsx +72 -1
  31. package/src/ai-elements/simple-model-ladder.tsx +108 -0
  32. package/src/ai-elements/speech-input.tsx +5 -3
  33. package/src/ai-elements/test-results.tsx +510 -0
  34. package/src/ai-elements/trace.tsx +243 -0
  35. package/src/ai-elements/use-streaming-disclosure.ts +50 -0
  36. package/src/ai-elements/web-preview.tsx +349 -0
  37. package/src/composer/input-area/ComposerSlashMenu.tsx +1 -1
  38. package/src/index.ts +66 -12
  39. package/src/kibo/avatar-stack.tsx +40 -0
  40. package/src/kibo/contribution-graph.tsx +539 -0
  41. package/src/kibo/cursor.tsx +72 -0
  42. package/src/kibo/gantt-features.tsx +439 -0
  43. package/src/kibo/gantt-header.tsx +181 -0
  44. package/src/kibo/gantt-provider.tsx +657 -0
  45. package/src/kibo/gantt-sidebar.tsx +157 -0
  46. package/src/kibo/gantt-timeline.tsx +154 -0
  47. package/src/kibo/index.ts +151 -0
  48. package/src/kibo/kanban.tsx +338 -0
  49. package/src/kibo/kanbanDropTarget.ts +25 -0
  50. package/src/kibo/list.tsx +162 -0
  51. package/src/kibo/theme-switcher.tsx +109 -0
  52. package/src/kibo/video-player.tsx +237 -0
  53. package/src/markdown/Markdown.tsx +112 -0
  54. package/src/markdown/MarkdownBlockquote.tsx +45 -0
  55. package/src/markdown/MarkdownCodeBlock.tsx +197 -0
  56. package/src/markdown/MarkdownInlineCode.tsx +40 -0
  57. package/src/markdown/MarkdownLink.tsx +92 -0
  58. package/src/markdown/codeFence.ts +57 -0
  59. package/src/markdown/codeHighlight.ts +138 -0
  60. package/src/markdown/incrementalBlocks.ts +182 -0
  61. package/src/markdown/index.ts +3 -0
  62. package/src/markdown/markdownClipboard.ts +178 -0
  63. package/src/markdown/mermaid.ts +155 -0
  64. package/src/markdown/rehypeGithubAlerts.ts +80 -0
  65. package/src/motion/presets.ts +24 -0
  66. package/src/ui/__snapshots__/alt-wrappers.test.tsx.snap +19 -0
  67. package/src/ui/__snapshots__/sonner-dom.test.tsx.snap +5 -0
  68. package/src/ui/_menu-parts.tsx +29 -0
  69. package/src/ui/accordion.alt.tsx +46 -53
  70. package/src/ui/accordion.tsx +59 -10
  71. package/src/ui/avatar.tsx +56 -0
  72. package/src/ui/badge.tsx +45 -21
  73. package/src/ui/border-beam.tsx +97 -0
  74. package/src/ui/button-group.tsx +50 -21
  75. package/src/ui/button.tsx +67 -45
  76. package/src/ui/calendar.tsx +48 -5
  77. package/src/ui/card.tsx +77 -8
  78. package/src/ui/carousel.tsx +60 -0
  79. package/src/ui/centered-spinner.tsx +30 -0
  80. package/src/ui/checkbox.tsx +34 -16
  81. package/src/ui/clear-input.alt.tsx +33 -75
  82. package/src/ui/clear-input.tsx +140 -15
  83. package/src/ui/collapsible.tsx +40 -2
  84. package/src/ui/command.tsx +143 -70
  85. package/src/ui/context-menu.tsx +159 -93
  86. package/src/ui/crossfade-icon.tsx +123 -0
  87. package/src/ui/dialog.tsx +154 -70
  88. package/src/ui/dropdown-menu.tsx +177 -117
  89. package/src/ui/hover-card.tsx +59 -17
  90. package/src/ui/input-group.alt.tsx +83 -83
  91. package/src/ui/input-group.tsx +129 -58
  92. package/src/ui/input.tsx +13 -4
  93. package/src/ui/label.tsx +11 -8
  94. package/src/ui/list-row.tsx +232 -0
  95. package/src/ui/loading-state.tsx +215 -0
  96. package/src/ui/popover.tsx +56 -23
  97. package/src/ui/progress.tsx +28 -16
  98. package/src/ui/refresh-spin-icon.tsx +41 -0
  99. package/src/ui/search-input.tsx +66 -15
  100. package/src/ui/select.tsx +171 -100
  101. package/src/ui/separator.tsx +23 -14
  102. package/src/ui/slider.tsx +21 -7
  103. package/src/ui/sonner.tsx +68 -30
  104. package/src/ui/spinner.tsx +229 -11
  105. package/src/ui/status-dot.tsx +84 -0
  106. package/src/ui/surface.tsx +49 -0
  107. package/src/ui/switch.tsx +29 -15
  108. package/src/ui/table.tsx +38 -16
  109. package/src/ui/tabs.tsx +123 -35
  110. package/src/ui/tabsSliding.ts +33 -1
  111. package/src/ui/textarea.tsx +13 -4
  112. package/src/ui/tooltip.tsx +50 -14
  113. package/src/ui/ui-config.tsx +149 -0
  114. package/src/utils/cat-color.ts +123 -0
  115. package/src/utils/dayjs.ts +6 -0
  116. package/src/utils/gesture.ts +59 -0
  117. package/src/utils/runtimeVisibility.ts +167 -0
  118. package/src/utils/sharedClock.ts +96 -0
  119. package/src/utils/surface-radius.ts +16 -0
  120. package/src/utils/useDragSensors.ts +58 -0
  121. package/tokens/examples/tokens-moved/Fixture.tsx +283 -0
  122. package/tokens/examples/tokens-moved/README.md +20 -0
  123. package/tokens/examples/tokens-moved/index.html +12 -0
  124. package/tokens/examples/tokens-moved/main.tsx +6 -0
  125. package/tokens/examples/tokens-moved/package.json +14 -0
  126. package/tokens/examples/tokens-moved/styles.css +12 -0
  127. package/tokens/examples/tokens-moved/tsconfig.json +14 -0
  128. package/tokens/examples/tokens-moved/vite.config.ts +11 -0
  129. package/tokens/src/css/border-beam.css +116 -0
  130. package/tokens/src/css/markdown.css +318 -0
@@ -0,0 +1,92 @@
1
+ import type { ComponentProps, ReactNode, SyntheticEvent } from "react";
2
+ import { Children } from "react";
3
+ import { IconBrandGithub, IconWorld } from "@tabler/icons-react";
4
+ import type { ExtraProps } from "streamdown";
5
+
6
+ /** Streamdown's placeholder href for a link whose `(url)` has not streamed yet. */
7
+ const INCOMPLETE_LINK = "streamdown:incomplete-link";
8
+ /** Break opportunities in a bare URL: after separators, never mid-word. */
9
+ const URL_BREAK_AFTER = /([/.?&=#_-]+)/;
10
+
11
+ /** Hosts whose favicon is a black glyph that vanishes in dark mode. */
12
+ const MONOCHROME_HOSTS = new Set(["github.com", "gist.github.com"]);
13
+ /** Hosts whose favicon failed this session; they get the globe from now on. */
14
+ const failedFavicons = new Set<string>();
15
+
16
+ function externalHost(href: string): string | null {
17
+ if (!URL.canParse(href)) return null;
18
+ const url = new URL(href);
19
+ return url.protocol === "https:" || url.protocol === "http:" ? url.host : null;
20
+ }
21
+
22
+ function forgetFavicon(event: SyntheticEvent<HTMLImageElement>, host: string) {
23
+ failedFavicons.add(host);
24
+ event.currentTarget.dataset.failed = "true";
25
+ }
26
+
27
+ /**
28
+ * A pasted URL is one unbreakable word, so it either overflows or, with
29
+ * `overflow-wrap: anywhere`, splits at an arbitrary letter. `<wbr>` after
30
+ * each separator lets it wrap where a reader expects.
31
+ */
32
+ export function breakableUrl(text: string): ReactNode[] {
33
+ return text
34
+ .split(URL_BREAK_AFTER)
35
+ .flatMap((part, index) =>
36
+ index % 2 === 1 ? [part, <wbr key={index} />] : part === "" ? [] : [part],
37
+ );
38
+ }
39
+
40
+ function LinkLabel({ children, href }: { children: ReactNode; href: string }) {
41
+ const parts = Children.toArray(children);
42
+ const only = parts.length === 1 ? parts[0] : undefined;
43
+ if (typeof only === "string" && (only === href || /^https?:\/\//.test(only))) {
44
+ return <>{breakableUrl(only)}</>;
45
+ }
46
+ return <>{children}</>;
47
+ }
48
+
49
+ /**
50
+ * `a` renderer. External links open in a new tab behind the site's own
51
+ * favicon (fetched from that host, never a third-party favicon service), with
52
+ * a globe when it has none and a brand glyph for GitHub. In-page anchors and other schemes stay plain.
53
+ */
54
+ export function MarkdownLink({ href, children }: ComponentProps<"a"> & ExtraProps) {
55
+ if (href === undefined || href === INCOMPLETE_LINK) {
56
+ return <span className="markdown-link">{children}</span>;
57
+ }
58
+ const host = externalHost(href);
59
+ if (host === null) {
60
+ return (
61
+ <a className="markdown-link" href={href}>
62
+ {children}
63
+ </a>
64
+ );
65
+ }
66
+ return (
67
+ <a
68
+ className="markdown-link"
69
+ href={href}
70
+ target="_blank"
71
+ rel="noopener noreferrer"
72
+ title={href}
73
+ >
74
+ {MONOCHROME_HOSTS.has(host) ? (
75
+ <IconBrandGithub className="markdown-link-icon" aria-hidden />
76
+ ) : failedFavicons.has(host) ? (
77
+ <IconWorld className="markdown-link-icon" aria-hidden />
78
+ ) : (
79
+ <img
80
+ className="markdown-link-icon"
81
+ src={`https://${host}/favicon.ico`}
82
+ alt=""
83
+ aria-hidden
84
+ loading="lazy"
85
+ referrerPolicy="no-referrer"
86
+ onError={(event) => forgetFavicon(event, host)}
87
+ />
88
+ )}
89
+ <LinkLabel href={href}>{children}</LinkLabel>
90
+ </a>
91
+ );
92
+ }
@@ -0,0 +1,57 @@
1
+ import type { ExtraProps } from "streamdown";
2
+
3
+ type HastElement = NonNullable<ExtraProps["node"]>;
4
+ type HastChild = HastElement["children"][number];
5
+
6
+ export interface FencedCode {
7
+ code: string;
8
+ /** Fence language as written (`ts`, `bash`), or "" for a bare fence. */
9
+ language: string;
10
+ /** File name from the fence meta (`title="a.ts"`, `file=a.ts`, `a.ts`). */
11
+ title: string | null;
12
+ }
13
+
14
+ const LANGUAGE_CLASS = /^language-(.+)$/;
15
+ const TITLE_ATTRIBUTE = /(?:^|\s)(?:title|file|filename)=(?:"([^"]+)"|'([^']+)'|(\S+))/;
16
+ /** A bare meta token that names a file: has a dot or slash, no `=` or braces. */
17
+ const BARE_FILE = /^[\w@~./\\-]*[./\\][\w@~./\\-]+$/;
18
+
19
+ function textOf(node: HastChild): string {
20
+ if (node.type === "text") return node.value;
21
+ if (node.type !== "element") return "";
22
+ return node.children.map(textOf).join("");
23
+ }
24
+
25
+ /** The file name a fence meta string points at, if any. */
26
+ export function parseFenceTitle(meta: string): string | null {
27
+ const attribute = TITLE_ATTRIBUTE.exec(meta);
28
+ if (attribute) return attribute[1] ?? attribute[2] ?? attribute[3] ?? null;
29
+ const bare = meta.trim().split(/\s+/)[0] ?? "";
30
+ return BARE_FILE.test(bare) && !/^\d/.test(bare) ? bare : null;
31
+ }
32
+
33
+ /**
34
+ * Reads a fenced code block off the hast `pre` react-markdown hands its
35
+ * renderer. Parsing the tree rather than the rendered `code` child keeps this
36
+ * typed: the child is an opaque element whose props TypeScript cannot see.
37
+ */
38
+ export function readFencedCode(pre: HastElement): FencedCode | null {
39
+ for (const child of pre.children) {
40
+ if (child.type !== "element" || child.tagName !== "code") continue;
41
+ const classes = child.properties.className;
42
+ let language = "";
43
+ if (Array.isArray(classes)) {
44
+ for (const name of classes) {
45
+ const match = LANGUAGE_CLASS.exec(String(name));
46
+ if (match?.[1]) language = match[1];
47
+ }
48
+ }
49
+ const meta = child.properties.metastring;
50
+ return {
51
+ code: textOf(child),
52
+ language,
53
+ title: typeof meta === "string" ? parseFenceTitle(meta) : null,
54
+ };
55
+ }
56
+ return null;
57
+ }
@@ -0,0 +1,138 @@
1
+ import { code as shiki } from "@streamdown/code";
2
+ import { useState, useSyncExternalStore } from "react";
3
+ import type { CodeHighlighterPlugin } from "streamdown";
4
+
5
+ /** A Shiki language id, as the plugin names it. */
6
+ type BundledLanguage = ReturnType<typeof shiki.getSupportedLanguages>[number];
7
+ /** Streamdown declares these without exporting them. */
8
+ type HighlightResult = NonNullable<ReturnType<CodeHighlighterPlugin["highlight"]>>;
9
+ export type HighlightToken = HighlightResult["tokens"][number][number];
10
+
11
+ /**
12
+ * Syntax highlighting for markdown code blocks, on the same Shiki plugin
13
+ * Streamdown ships (JS regex engine, one lazily-created highlighter per
14
+ * language, `github-light` / `github-dark` tokens in a single pass).
15
+ *
16
+ * The plugin's own cache is keyed by a hash of the code's length and edges and
17
+ * starts a fresh job on every uncached call, so this module keeps a small
18
+ * cache of its own that dedupes in-flight work and notifies subscribers — the
19
+ * shape `useSyncExternalStore` needs, with no effect.
20
+ */
21
+
22
+ const THEMES = shiki.getThemes();
23
+ /** Fence language (lowercased) → Shiki id. Aliases (`ts`, `sh`, `py`) are
24
+ * keys too; the plugin resolves them to their grammar. */
25
+ const LANGUAGES = new Map<string, BundledLanguage>(
26
+ shiki.getSupportedLanguages().map((id) => [id, id]),
27
+ );
28
+ /** Enough for every code block on screen plus scroll-back; oldest dropped. */
29
+ const CACHE_LIMIT = 200;
30
+
31
+ type Entry = { status: "pending" } | { status: "done"; result: HighlightResult };
32
+
33
+ const cache = new Map<string, Entry>();
34
+ const listeners = new Map<string, Set<() => void>>();
35
+
36
+ function cacheKey(language: BundledLanguage, source: string): string {
37
+ return `${language}\u0000${source}`;
38
+ }
39
+
40
+ function store(key: string, entry: Entry): void {
41
+ cache.delete(key);
42
+ cache.set(key, entry);
43
+ if (cache.size <= CACHE_LIMIT) return;
44
+ const oldest = cache.keys().next().value;
45
+ if (oldest !== undefined && !listeners.has(oldest)) cache.delete(oldest);
46
+ }
47
+
48
+ function request(language: BundledLanguage, source: string): HighlightResult | null {
49
+ const key = cacheKey(language, source);
50
+ const hit = cache.get(key);
51
+ if (hit !== undefined) return hit.status === "done" ? hit.result : null;
52
+
53
+ store(key, { status: "pending" });
54
+ const sync = shiki.highlight(
55
+ { code: source, language, themes: THEMES },
56
+ (result) => {
57
+ store(key, { status: "done", result });
58
+ for (const notify of listeners.get(key) ?? []) notify();
59
+ },
60
+ );
61
+ if (sync === null) return null;
62
+ store(key, { status: "done", result: sync });
63
+ return sync;
64
+ }
65
+
66
+ function subscribe(key: string, notify: () => void): () => void {
67
+ const set = listeners.get(key) ?? new Set();
68
+ set.add(notify);
69
+ listeners.set(key, set);
70
+ return () => {
71
+ set.delete(notify);
72
+ if (set.size === 0) listeners.delete(key);
73
+ };
74
+ }
75
+
76
+ /**
77
+ * What to highlight. A finished block is highlighted whole. A block still
78
+ * streaming is highlighted up to its last complete line only: the tokens for
79
+ * finished lines never change, so each new line costs one job instead of one
80
+ * per token, and the partial line renders plain until its newline lands.
81
+ */
82
+ export function highlightSource(code: string, isIncomplete: boolean): string {
83
+ const trimmed = code.replace(/\n+$/, "");
84
+ if (!isIncomplete) return trimmed;
85
+ const lastBreak = code.lastIndexOf("\n");
86
+ return lastBreak <= 0 ? "" : code.slice(0, lastBreak);
87
+ }
88
+
89
+ export type CodeLine = { text: string; tokens: HighlightToken[] | null };
90
+
91
+ /**
92
+ * Pairs each line of `code` with its highlighted tokens when a highlight of a
93
+ * prefix of `code` is available; lines past that prefix stay plain.
94
+ */
95
+ export function buildCodeLines(
96
+ code: string,
97
+ highlighted: { source: string; result: HighlightResult } | null,
98
+ ): CodeLine[] {
99
+ const lines = code.replace(/\n+$/, "").split("\n");
100
+ const usable =
101
+ highlighted !== null &&
102
+ highlighted.source.length > 0 &&
103
+ (code === highlighted.source || code.startsWith(`${highlighted.source}\n`));
104
+ return lines.map((text, index) => ({
105
+ text,
106
+ tokens: usable ? (highlighted.result.tokens[index] ?? null) : null,
107
+ }));
108
+ }
109
+
110
+ /**
111
+ * Highlighted lines for a code block. While a new highlight is in flight the
112
+ * previous one is kept on screen (it still covers every line it was made
113
+ * for), so streaming never flashes back to plain text.
114
+ */
115
+ export function useHighlightedLines(
116
+ code: string,
117
+ language: string,
118
+ isIncomplete: boolean,
119
+ ): CodeLine[] {
120
+ const bundled = LANGUAGES.get(language.toLowerCase()) ?? null;
121
+ const source = bundled === null ? "" : highlightSource(code, isIncomplete);
122
+ const key = bundled === null ? "" : cacheKey(bundled, source);
123
+
124
+ const result = useSyncExternalStore(
125
+ (notify) => (key === "" ? () => {} : subscribe(key, notify)),
126
+ () => (bundled === null || source === "" ? null : request(bundled, source)),
127
+ );
128
+
129
+ const [shown, setShown] = useState<{
130
+ source: string;
131
+ result: HighlightResult;
132
+ } | null>(null);
133
+ if (result !== null && shown?.result !== result) {
134
+ setShown({ source, result });
135
+ }
136
+
137
+ return buildCodeLines(code, result !== null ? { source, result } : shown);
138
+ }
@@ -0,0 +1,182 @@
1
+ import { parseMarkdownIntoBlocks } from "streamdown";
2
+
3
+ /**
4
+ * Streamdown already renders a streamed reply block by block: each block is
5
+ * memoised on its own text, so appending a token only re-renders the block
6
+ * still being written. What it does *not* skip is the split itself — the
7
+ * default `parseMarkdownIntoBlocks` runs marked's block lexer over the whole
8
+ * reply on every token, so a long answer costs O(length) lexing per token and
9
+ * O(length^2) over the turn. That is the work behind the mid-stream freezes on
10
+ * a slow laptop: the DOM update is small, the re-parse before it is not.
11
+ *
12
+ * This is the same split, done incrementally. Blocks already produced from a
13
+ * byte-identical prefix are reused verbatim and only the tail is re-lexed, so
14
+ * the per-token cost is one prefix comparison plus lexing the paragraph being
15
+ * written. Identical output, not an approximation — see the invariant test.
16
+ *
17
+ * Reuse is only allowed up to a block that ends on a blank line. A blank line
18
+ * closes every leaf block in CommonMark, so marked's tokeniser starts the next
19
+ * block from a clean state and cannot reinterpret what came before it (a
20
+ * setext underline, a lazy list continuation). Without such a boundary we fall
21
+ * back to a full parse.
22
+ */
23
+
24
+ /** How many recent splits to keep. In practice: the streaming reply, plus the
25
+ * streamed reasoning prose rendered alongside it, plus headroom. */
26
+ const CACHE_SIZE = 4;
27
+
28
+ interface BlockSplit {
29
+ source: string;
30
+ blocks: string[];
31
+ /**
32
+ * `blocks.join("") === source`, so a block index maps to a source offset.
33
+ * False when marked normalised the input (CRLF, say); such a split can be
34
+ * returned but never used as a base.
35
+ */
36
+ exact: boolean;
37
+ /** Source offset at which block `i` ends. */
38
+ offsets: number[];
39
+ /** Whether block `i` ends on a blank line, i.e. is a safe cut point. */
40
+ endsClean: boolean[];
41
+ /** Longest reusable prefix: block count, source length, and the text itself.
42
+ * Precomputed so a tick costs one string comparison, not a per-block scan. */
43
+ cleanCount: number;
44
+ cleanLength: number;
45
+ cleanText: string;
46
+ }
47
+
48
+ const cache: BlockSplit[] = [];
49
+
50
+ /** A block that ends on a blank line closes cleanly. */
51
+ const ENDS_ON_BLANK_LINE = /\n[ \t]*\n$/;
52
+
53
+ /**
54
+ * Footnotes make Streamdown emit the whole document as one block, because a
55
+ * reference resolves against a definition anywhere in it. Any document that
56
+ * could take that path is parsed whole — a prefix split from before the
57
+ * footnote appeared would not match what a full parse produces now.
58
+ */
59
+ function mayContainFootnotes(markdown: string): boolean {
60
+ return markdown.includes("[^");
61
+ }
62
+
63
+ /**
64
+ * Indexes a split so later ticks can slice it by offset without re-scanning.
65
+ * The final block is never a cut point: it is the one still being written.
66
+ */
67
+ function describeSplit(
68
+ source: string,
69
+ blocks: string[],
70
+ exact: boolean,
71
+ ): BlockSplit {
72
+ const offsets: number[] = [];
73
+ const endsClean: boolean[] = [];
74
+ let offset = 0;
75
+ let cleanCount = 0;
76
+ let cleanLength = 0;
77
+ for (let i = 0; i < blocks.length; i++) {
78
+ const block = blocks[i] ?? "";
79
+ offset += block.length;
80
+ offsets.push(offset);
81
+ const clean = ENDS_ON_BLANK_LINE.test(block);
82
+ endsClean.push(clean);
83
+ if (clean && i < blocks.length - 1) {
84
+ cleanCount = i + 1;
85
+ cleanLength = offset;
86
+ }
87
+ }
88
+ return {
89
+ source,
90
+ blocks,
91
+ exact,
92
+ offsets,
93
+ endsClean,
94
+ cleanCount,
95
+ cleanLength,
96
+ cleanText: exact && cleanLength > 0 ? source.slice(0, cleanLength) : "",
97
+ };
98
+ }
99
+
100
+ function fullSplit(markdown: string): BlockSplit {
101
+ const blocks = parseMarkdownIntoBlocks(markdown);
102
+ return describeSplit(markdown, blocks, blocks.join("") === markdown);
103
+ }
104
+
105
+ /**
106
+ * How much of `base` is a byte-identical, cleanly-closed prefix of `markdown`.
107
+ * The common case — the reply only grew — is one string comparison. The slow
108
+ * walk is for a rewritten tail: Streamdown closes an open code fence before
109
+ * splitting, so the string it hands us can change at the end between ticks.
110
+ */
111
+ function reusablePrefix(
112
+ base: BlockSplit,
113
+ markdown: string,
114
+ ): { count: number; length: number } {
115
+ if (base.cleanLength === 0) return { count: 0, length: 0 };
116
+ if (markdown.startsWith(base.cleanText)) {
117
+ return { count: base.cleanCount, length: base.cleanLength };
118
+ }
119
+ let count = 0;
120
+ let length = 0;
121
+ for (let i = 0; i < base.blocks.length - 1; i++) {
122
+ const block = base.blocks[i];
123
+ const end = base.offsets[i];
124
+ if (block === undefined || end === undefined) break;
125
+ if (!markdown.startsWith(block, end - block.length)) break;
126
+ if (base.endsClean[i] === true) {
127
+ count = i + 1;
128
+ length = end;
129
+ }
130
+ }
131
+ return { count, length };
132
+ }
133
+
134
+ function splitIncrementally(markdown: string): BlockSplit {
135
+ let best: { base: BlockSplit; count: number; length: number } | null = null;
136
+ for (const base of cache) {
137
+ if (!base.exact) continue;
138
+ const prefix = reusablePrefix(base, markdown);
139
+ if (prefix.length === 0) continue;
140
+ if (best === null || prefix.length > best.length) {
141
+ best = { base, ...prefix };
142
+ }
143
+ }
144
+ if (best === null) return fullSplit(markdown);
145
+
146
+ const tail = markdown.slice(best.length);
147
+ const tailBlocks = parseMarkdownIntoBlocks(tail);
148
+ return describeSplit(
149
+ markdown,
150
+ [...best.base.blocks.slice(0, best.count), ...tailBlocks],
151
+ tailBlocks.join("") === tail,
152
+ );
153
+ }
154
+
155
+ /**
156
+ * Drop-in replacement for Streamdown's `parseMarkdownIntoBlocksFn`. Stable
157
+ * module-level identity, so passing it does not invalidate Streamdown's own
158
+ * memo on the split.
159
+ */
160
+ export function parseMarkdownIntoBlocksIncremental(markdown: string): string[] {
161
+ for (let i = 0; i < cache.length; i++) {
162
+ const entry = cache[i];
163
+ if (entry === undefined || entry.source !== markdown) continue;
164
+ if (i > 0) {
165
+ cache.splice(i, 1);
166
+ cache.unshift(entry);
167
+ }
168
+ return entry.blocks;
169
+ }
170
+
171
+ const split = mayContainFootnotes(markdown)
172
+ ? fullSplit(markdown)
173
+ : splitIncrementally(markdown);
174
+ cache.unshift(split);
175
+ if (cache.length > CACHE_SIZE) cache.length = CACHE_SIZE;
176
+ return split.blocks;
177
+ }
178
+
179
+ /** Test seam: the cache is module state shared by every Streamdown instance. */
180
+ export function resetIncrementalMarkdownBlocks(): void {
181
+ cache.length = 0;
182
+ }
@@ -0,0 +1,3 @@
1
+ export { Markdown } from "./Markdown";
2
+ export type { MarkdownProps } from "./Markdown";
3
+ export { MarkdownLink } from "./MarkdownLink";
@@ -0,0 +1,178 @@
1
+ import type { ClipboardEvent } from "react";
2
+
3
+ /**
4
+ * Copying rendered markdown puts markdown on the clipboard, not the flattened
5
+ * text the browser produces (lists lose their bullets, code loses its fence,
6
+ * links lose their URL). The selection's DOM is walked back into markdown;
7
+ * `text/html` still carries the rich fragment for editors that want it.
8
+ *
9
+ * Elements can speak for themselves with `data-markdown-copy`: `"skip"` drops
10
+ * them (code block headers), any other value replaces their content (alert
11
+ * titles emit their `[!NOTE]` marker).
12
+ */
13
+
14
+ const SKIPPED_TAGS = new Set(["BUTTON", "SVG", "STYLE", "SCRIPT", "TEMPLATE", "WBR"]);
15
+
16
+ function isElement(node: Node): node is HTMLElement {
17
+ return node.nodeType === Node.ELEMENT_NODE;
18
+ }
19
+
20
+ function skipped(element: HTMLElement): boolean {
21
+ return (
22
+ SKIPPED_TAGS.has(element.tagName.toUpperCase()) ||
23
+ element.dataset.markdownCopy === "skip" ||
24
+ element.getAttribute("aria-hidden") === "true" ||
25
+ element.classList.contains("sr-only")
26
+ );
27
+ }
28
+
29
+ /** A fence longer than any backtick run inside the code. */
30
+ function fence(code: string): string {
31
+ const longest = Math.max(0, ...(code.match(/`+/g) ?? []).map((run) => run.length));
32
+ return "`".repeat(Math.max(3, longest + 1));
33
+ }
34
+
35
+ function children(node: Node): string {
36
+ return Array.from(node.childNodes, serialize).join("");
37
+ }
38
+
39
+ function prefixLines(text: string, first: string, rest: string): string {
40
+ return text
41
+ .split("\n")
42
+ .map((line, index) => (line === "" && index > 0 ? rest.trimEnd() : (index === 0 ? first : rest) + line))
43
+ .join("\n");
44
+ }
45
+
46
+ function list(element: HTMLElement): string {
47
+ const ordered = element.tagName === "OL";
48
+ let number = Number(element.getAttribute("start") ?? "1") || 1;
49
+ const items: string[] = [];
50
+ for (const child of Array.from(element.children)) {
51
+ if (!isElement(child) || child.tagName !== "LI") continue;
52
+ const marker = ordered ? `${number++}. ` : "- ";
53
+ const checkbox = child.querySelector(":scope > input[type=checkbox], :scope > p > input[type=checkbox]");
54
+ const task = checkbox instanceof HTMLInputElement ? (checkbox.checked ? "[x] " : "[ ] ") : "";
55
+ const body = children(child).trim();
56
+ items.push(prefixLines(task + body, marker, " ".repeat(marker.length)));
57
+ }
58
+ return `${items.join("\n")}\n\n`;
59
+ }
60
+
61
+ function table(element: HTMLElement): string {
62
+ const rows = Array.from(element.querySelectorAll("tr"), (row) =>
63
+ Array.from(row.children, (cell) => children(cell).trim().replace(/\|/g, "\\|")),
64
+ );
65
+ const [head, ...body] = rows;
66
+ if (!head) return "";
67
+ const line = (cells: string[]) => `| ${cells.join(" | ")} |`;
68
+ return `${[line(head), line(head.map(() => "---")), ...body.map(line)].join("\n")}\n\n`;
69
+ }
70
+
71
+ function codeBlock(element: HTMLElement): string {
72
+ const code = (element.querySelector("pre")?.textContent ?? element.textContent ?? "").replace(/\n$/, "");
73
+ const language = element.closest("[data-language]")?.getAttribute("data-language") ?? "";
74
+ const marker = fence(code);
75
+ return `${marker}${language}\n${code}\n${marker}\n\n`;
76
+ }
77
+
78
+ function math(element: HTMLElement, display: boolean): string {
79
+ const tex = element.querySelector('annotation[encoding="application/x-tex"]')?.textContent ?? element.textContent ?? "";
80
+ return display ? `$$\n${tex}\n$$\n\n` : `$${tex}$`;
81
+ }
82
+
83
+ function serialize(node: Node): string {
84
+ if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? "";
85
+ if (!isElement(node) || skipped(node)) return "";
86
+
87
+ const override = node.dataset.markdownCopy;
88
+ if (override !== undefined) return override;
89
+
90
+ if (node.classList.contains("katex-display")) return math(node, true);
91
+ if (node.classList.contains("katex")) return math(node, false);
92
+ if (node.classList.contains("markdown-code") || node.tagName === "PRE") return codeBlock(node);
93
+
94
+ const content = () => children(node);
95
+ switch (node.tagName) {
96
+ case "BR":
97
+ return "\n";
98
+ case "STRONG":
99
+ case "B":
100
+ return `**${content()}**`;
101
+ case "EM":
102
+ case "I":
103
+ return `*${content()}*`;
104
+ case "DEL":
105
+ case "S":
106
+ return `~~${content()}~~`;
107
+ case "CODE": {
108
+ const text = node.textContent ?? "";
109
+ return text.includes("`") ? `\`\` ${text} \`\`` : `\`${text}\``;
110
+ }
111
+ case "A": {
112
+ const href = node.getAttribute("href");
113
+ const label = content();
114
+ if (!href || href.startsWith("streamdown:")) return label;
115
+ return label === href ? href : `[${label}](${href})`;
116
+ }
117
+ case "IMG": {
118
+ const src = node.getAttribute("src");
119
+ return src ? `![${node.getAttribute("alt") ?? ""}](${src})` : "";
120
+ }
121
+ case "INPUT":
122
+ return "";
123
+ case "H1":
124
+ case "H2":
125
+ case "H3":
126
+ case "H4":
127
+ case "H5":
128
+ case "H6":
129
+ return `${"#".repeat(Number(node.tagName[1]))} ${content().trim()}\n\n`;
130
+ case "P":
131
+ return `${content().trim()}\n\n`;
132
+ case "HR":
133
+ return "---\n\n";
134
+ case "UL":
135
+ case "OL":
136
+ return list(node);
137
+ case "TABLE":
138
+ return table(node);
139
+ case "BLOCKQUOTE":
140
+ return `${prefixLines(children(node).trim(), "> ", "> ")}\n\n`;
141
+ default:
142
+ if (node.getAttribute("role") === "note" && node.dataset.alert) {
143
+ return `${prefixLines(children(node).trim().replace(/^(\[![A-Z]+\])\s*/, "$1\n"), "> ", "> ")}\n\n`;
144
+ }
145
+ return content();
146
+ }
147
+ }
148
+
149
+ /** Markdown for a DOM fragment, with blank-line runs collapsed. */
150
+ export function fragmentToMarkdown(fragment: DocumentFragment | HTMLElement): string {
151
+ return children(fragment)
152
+ .replace(/[ \t]+\n/g, "\n")
153
+ .replace(/\n{3,}/g, "\n\n")
154
+ .trim();
155
+ }
156
+
157
+ /**
158
+ * `onCopy` for a rendered markdown root. A selection inside one code block is
159
+ * left to the browser, which already copies it verbatim.
160
+ */
161
+ export function copyMarkdownSelection(event: ClipboardEvent<HTMLElement>): void {
162
+ const selection = window.getSelection();
163
+ if (!selection || selection.isCollapsed || selection.rangeCount === 0) return;
164
+ const range = selection.getRangeAt(0);
165
+ const ancestor = range.commonAncestorContainer;
166
+ const container = isElement(ancestor) ? ancestor : ancestor.parentElement;
167
+ if (container?.closest("pre")) return;
168
+
169
+ const fragment = range.cloneContents();
170
+ const markdown = fragmentToMarkdown(fragment);
171
+ if (markdown === "") return;
172
+
173
+ const html = document.createElement("div");
174
+ html.append(fragment);
175
+ event.clipboardData.setData("text/plain", markdown);
176
+ event.clipboardData.setData("text/html", html.innerHTML);
177
+ event.preventDefault();
178
+ }