@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.
- package/README.md +251 -3
- package/package.json +13 -5
- package/shell/src/SidebarResize.tsx +1 -4
- package/src/ai-elements/__snapshots__/queue.test.tsx.snap +11 -0
- package/src/ai-elements/activity-question-utils.ts +26 -0
- package/src/ai-elements/activity-question.tsx +159 -0
- package/src/ai-elements/activity-shared.tsx +459 -0
- package/src/ai-elements/activity-step-detail.tsx +115 -0
- package/src/ai-elements/activity-step-label.ts +981 -0
- package/src/ai-elements/activity-tasks-utils.ts +199 -0
- package/src/ai-elements/activity-tasks.tsx +770 -0
- package/src/ai-elements/artifact.tsx +162 -52
- package/src/ai-elements/attachments.tsx +62 -32
- package/src/ai-elements/code-block.tsx +76 -22
- package/src/ai-elements/context.tsx +370 -0
- package/src/ai-elements/conversation.tsx +186 -62
- package/src/ai-elements/file-chip.tsx +53 -0
- package/src/ai-elements/message.tsx +161 -50
- package/src/ai-elements/model-picker-content.tsx +505 -0
- package/src/ai-elements/model-picker-types.ts +56 -0
- package/src/ai-elements/model-picker.tsx +301 -0
- package/src/ai-elements/model-selector.tsx +102 -21
- package/src/ai-elements/plan.tsx +130 -0
- package/src/ai-elements/prompt-input-settings.tsx +144 -0
- package/src/ai-elements/prompt-input-speech.tsx +189 -0
- package/src/ai-elements/prompt-input-traits.tsx +281 -0
- package/src/ai-elements/prompt-input.tsx +320 -134
- package/src/ai-elements/provider-icon.tsx +133 -0
- package/src/ai-elements/queue.tsx +293 -9
- package/src/ai-elements/shimmer.tsx +72 -1
- package/src/ai-elements/simple-model-ladder.tsx +108 -0
- package/src/ai-elements/speech-input.tsx +5 -3
- package/src/ai-elements/test-results.tsx +510 -0
- package/src/ai-elements/trace.tsx +243 -0
- package/src/ai-elements/use-streaming-disclosure.ts +50 -0
- package/src/ai-elements/web-preview.tsx +349 -0
- package/src/composer/input-area/ComposerSlashMenu.tsx +1 -1
- package/src/index.ts +66 -12
- package/src/kibo/avatar-stack.tsx +40 -0
- package/src/kibo/contribution-graph.tsx +539 -0
- package/src/kibo/cursor.tsx +72 -0
- package/src/kibo/gantt-features.tsx +439 -0
- package/src/kibo/gantt-header.tsx +181 -0
- package/src/kibo/gantt-provider.tsx +657 -0
- package/src/kibo/gantt-sidebar.tsx +157 -0
- package/src/kibo/gantt-timeline.tsx +154 -0
- package/src/kibo/index.ts +151 -0
- package/src/kibo/kanban.tsx +338 -0
- package/src/kibo/kanbanDropTarget.ts +25 -0
- package/src/kibo/list.tsx +162 -0
- package/src/kibo/theme-switcher.tsx +109 -0
- package/src/kibo/video-player.tsx +237 -0
- package/src/markdown/Markdown.tsx +112 -0
- package/src/markdown/MarkdownBlockquote.tsx +45 -0
- package/src/markdown/MarkdownCodeBlock.tsx +197 -0
- package/src/markdown/MarkdownInlineCode.tsx +40 -0
- package/src/markdown/MarkdownLink.tsx +92 -0
- package/src/markdown/codeFence.ts +57 -0
- package/src/markdown/codeHighlight.ts +138 -0
- package/src/markdown/incrementalBlocks.ts +182 -0
- package/src/markdown/index.ts +3 -0
- package/src/markdown/markdownClipboard.ts +178 -0
- package/src/markdown/mermaid.ts +155 -0
- package/src/markdown/rehypeGithubAlerts.ts +80 -0
- package/src/motion/presets.ts +24 -0
- package/src/ui/__snapshots__/alt-wrappers.test.tsx.snap +19 -0
- package/src/ui/__snapshots__/sonner-dom.test.tsx.snap +5 -0
- package/src/ui/_menu-parts.tsx +29 -0
- package/src/ui/accordion.alt.tsx +46 -53
- package/src/ui/accordion.tsx +59 -10
- package/src/ui/avatar.tsx +56 -0
- package/src/ui/badge.tsx +45 -21
- package/src/ui/border-beam.tsx +97 -0
- package/src/ui/button-group.tsx +50 -21
- package/src/ui/button.tsx +67 -45
- package/src/ui/calendar.tsx +48 -5
- package/src/ui/card.tsx +77 -8
- package/src/ui/carousel.tsx +60 -0
- package/src/ui/centered-spinner.tsx +30 -0
- package/src/ui/checkbox.tsx +34 -16
- package/src/ui/clear-input.alt.tsx +33 -75
- package/src/ui/clear-input.tsx +140 -15
- package/src/ui/collapsible.tsx +40 -2
- package/src/ui/command.tsx +143 -70
- package/src/ui/context-menu.tsx +159 -93
- package/src/ui/crossfade-icon.tsx +123 -0
- package/src/ui/dialog.tsx +154 -70
- package/src/ui/dropdown-menu.tsx +177 -117
- package/src/ui/hover-card.tsx +59 -17
- package/src/ui/input-group.alt.tsx +83 -83
- package/src/ui/input-group.tsx +129 -58
- package/src/ui/input.tsx +13 -4
- package/src/ui/label.tsx +11 -8
- package/src/ui/list-row.tsx +232 -0
- package/src/ui/loading-state.tsx +215 -0
- package/src/ui/popover.tsx +56 -23
- package/src/ui/progress.tsx +28 -16
- package/src/ui/refresh-spin-icon.tsx +41 -0
- package/src/ui/search-input.tsx +66 -15
- package/src/ui/select.tsx +171 -100
- package/src/ui/separator.tsx +23 -14
- package/src/ui/slider.tsx +21 -7
- package/src/ui/sonner.tsx +68 -30
- package/src/ui/spinner.tsx +229 -11
- package/src/ui/status-dot.tsx +84 -0
- package/src/ui/surface.tsx +49 -0
- package/src/ui/switch.tsx +29 -15
- package/src/ui/table.tsx +38 -16
- package/src/ui/tabs.tsx +123 -35
- package/src/ui/tabsSliding.ts +33 -1
- package/src/ui/textarea.tsx +13 -4
- package/src/ui/tooltip.tsx +50 -14
- package/src/ui/ui-config.tsx +149 -0
- package/src/utils/cat-color.ts +123 -0
- package/src/utils/dayjs.ts +6 -0
- package/src/utils/gesture.ts +59 -0
- package/src/utils/runtimeVisibility.ts +167 -0
- package/src/utils/sharedClock.ts +96 -0
- package/src/utils/surface-radius.ts +16 -0
- package/src/utils/useDragSensors.ts +58 -0
- package/tokens/examples/tokens-moved/Fixture.tsx +283 -0
- package/tokens/examples/tokens-moved/README.md +20 -0
- package/tokens/examples/tokens-moved/index.html +12 -0
- package/tokens/examples/tokens-moved/main.tsx +6 -0
- package/tokens/examples/tokens-moved/package.json +14 -0
- package/tokens/examples/tokens-moved/styles.css +12 -0
- package/tokens/examples/tokens-moved/tsconfig.json +14 -0
- package/tokens/examples/tokens-moved/vite.config.ts +11 -0
- package/tokens/src/css/border-beam.css +116 -0
- 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,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 ? `` : "";
|
|
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
|
+
}
|