Compare commits

...
Author SHA1 Message Date
abeatrix b0c83b4281 chore: reduce cline-hub package size
Implemented the hub webview bundle cleanup.
Changed:
Added a shared Streamdown plugin module at streamdown-plugins.ts that uses the repo’s Shiki 4 dependency with async language/theme loading and lazy Mermaid loading.
Removed direct @streamdown/code usage from message.tsx and reasoning.tsx.
Updated code-block.tsx to avoid importing Shiki’s full bundle.
Added Vite/Rolldown chunk grouping in vite.config.ts, with the warning limit set to cover isolated async Mermaid/Shiki grammar chunks.
Removed @streamdown/code from the hub webview package and synced bun.lock.
Verified:
bun -F @cline/cline-hub build:webview exits 0 with no chunk-size warning.
bun -F @cline/cli build exits 0.
2026-06-05 15:01:13 -07:00
8 changed files with 315 additions and 62 deletions
-1
View File
@@ -14,7 +14,6 @@
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@rive-app/react-webgl2": "^4.27.2",
"@streamdown/cjk": "^1.0.3",
"@streamdown/code": "^1.1.1",
"@streamdown/math": "^1.0.2",
"@streamdown/mermaid": "^1.0.2",
"@tailwindcss/vite": "^4.2.1",
@@ -10,13 +10,9 @@ import {
useRef,
useState,
} from "react";
import type {
BundledLanguage,
BundledTheme,
HighlighterGeneric,
ThemedToken,
} from "shiki";
import { createHighlighter } from "shiki";
import type { HighlighterCore, ThemedToken } from "shiki/core";
import { createHighlighterCore } from "shiki/core";
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
import { Button } from "@/components/ui/button";
import {
Select,
@@ -108,10 +104,12 @@ const LineSpan = ({
// Types
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: BundledLanguage;
language: SupportedCodeLanguage;
showLineNumbers?: boolean;
};
type SupportedCodeLanguage = "json" | "typescript";
interface TokenizedCode {
tokens: ThemedToken[][];
fg: string;
@@ -128,10 +126,7 @@ const CodeBlockContext = createContext<CodeBlockContextType>({
});
// Highlighter cache (singleton per language)
const highlighterCache = new Map<
string,
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
>();
const highlighterCache = new Map<string, Promise<HighlighterCore>>();
// Token cache
const tokensCache = new Map<string, TokenizedCode>();
@@ -139,23 +134,31 @@ const tokensCache = new Map<string, TokenizedCode>();
// Subscribers for async token updates
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
const getTokensCacheKey = (code: string, language: SupportedCodeLanguage) => {
const start = code.slice(0, 100);
const end = code.length > 100 ? code.slice(-100) : "";
return `${language}:${code.length}:${start}:${end}`;
};
const getHighlighter = (
language: BundledLanguage,
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
language: SupportedCodeLanguage,
): Promise<HighlighterCore> => {
const cached = highlighterCache.get(language);
if (cached) {
return cached;
}
const highlighterPromise = createHighlighter({
langs: [language],
themes: ["github-light", "github-dark"],
const highlighterPromise = createHighlighterCore({
engine: createJavaScriptRegexEngine(),
langs: [
language === "typescript"
? () => import("shiki/dist/langs/typescript.mjs")
: () => import("shiki/dist/langs/json.mjs"),
],
themes: [
() => import("shiki/dist/themes/github-light.mjs"),
() => import("shiki/dist/themes/github-dark.mjs"),
],
});
highlighterCache.set(language, highlighterPromise);
@@ -181,7 +184,7 @@ const createRawTokens = (code: string): TokenizedCode => ({
// Synchronous highlight with callback for async results
export const highlightCode = (
code: string,
language: BundledLanguage,
language: SupportedCodeLanguage,
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
callback?: (result: TokenizedCode) => void,
): TokenizedCode | null => {
@@ -376,7 +379,7 @@ export const CodeBlockContent = ({
showLineNumbers = false,
}: {
code: string;
language: BundledLanguage;
language: SupportedCodeLanguage;
showLineNumbers?: boolean;
}) => {
// Memoized raw tokens for immediate display
@@ -1,9 +1,5 @@
"use client";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import type { UIMessage } from "ai";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
@@ -25,6 +21,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { streamdownPlugins } from "@/lib/streamdown-plugins";
import { cn } from "@/lib/utils";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
@@ -318,8 +315,6 @@ export const MessageBranchPage = ({
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
const streamdownPlugins = { cjk, code, math, mermaid };
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
@@ -0,0 +1,20 @@
import { createContext, useContext } from "react";
export interface ReasoningContextValue {
isStreaming: boolean;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
duration: number | undefined;
}
export const ReasoningContext = createContext<ReasoningContextValue | null>(
null,
);
export const useReasoning = () => {
const context = useContext(ReasoningContext);
if (!context) {
throw new Error("Reasoning components must be used within Reasoning");
}
return context;
};
@@ -1,49 +1,21 @@
"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Streamdown } from "streamdown";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { streamdownPlugins } from "@/lib/streamdown-plugins";
import { cn } from "@/lib/utils";
import { ReasoningContext, useReasoning } from "./reasoning-context";
import { Shimmer } from "./shimmer";
interface ReasoningContextValue {
isStreaming: boolean;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
duration: number | undefined;
}
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
export const useReasoning = () => {
const context = useContext(ReasoningContext);
if (!context) {
throw new Error("Reasoning components must be used within Reasoning");
}
return context;
};
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean;
open?: boolean;
@@ -204,8 +176,6 @@ export type ReasoningContentProps = ComponentProps<
children: string;
};
const streamdownPlugins = { cjk, code, math, mermaid };
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
@@ -0,0 +1,221 @@
import { cjk } from "@streamdown/cjk";
import { math } from "@streamdown/math";
import { createHighlighterCore, type HighlighterCore } from "shiki/core";
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
import {
type BundledLanguage,
bundledLanguages,
bundledLanguagesInfo,
} from "shiki/langs";
import type {
CodeHighlighterPlugin,
DiagramPlugin,
PluginConfig,
ThemeInput,
} from "streamdown";
interface HighlightResult {
bg?: string;
fg?: string;
rootStyle?: string | false;
tokens: {
bgColor?: string;
color?: string;
content: string;
htmlAttrs?: Record<string, string>;
htmlStyle?: Record<string, string>;
offset?: number;
}[][];
}
const supportedLanguages = Object.keys(bundledLanguages) as BundledLanguage[];
const languageAliases = new Map<string, BundledLanguage>(
bundledLanguagesInfo.flatMap((language) =>
(language.aliases ?? []).map((alias) => [
alias,
language.id as BundledLanguage,
]),
),
);
const supportedLanguageSet = new Set<string>(supportedLanguages);
const defaultThemes = ["github-light", "github-dark"] as [
ThemeInput,
ThemeInput,
];
const highlighterCache = new Map<string, Promise<HighlighterCore>>();
const tokenCache = new Map<string, HighlightResult>();
const subscribers = new Map<string, Set<(result: HighlightResult) => void>>();
const normalizeLanguage = (language: string): BundledLanguage | null => {
const normalized = language.trim().toLowerCase();
if (supportedLanguageSet.has(normalized)) {
return normalized as BundledLanguage;
}
return languageAliases.get(normalized) ?? null;
};
const getThemeName = (theme: ThemeInput) =>
typeof theme === "string" ? theme : theme.name;
const getCacheKey = (
code: string,
language: BundledLanguage,
themes: [ThemeInput, ThemeInput],
) => {
const start = code.slice(0, 100);
const end = code.length > 100 ? code.slice(-100) : "";
return `${language}:${getThemeName(themes[0])}:${getThemeName(themes[1])}:${code.length}:${start}:${end}`;
};
const getHighlighter = (language: BundledLanguage) => {
const cached = highlighterCache.get(language);
if (cached) {
return cached;
}
const highlighter = createHighlighterCore({
engine: createJavaScriptRegexEngine({ forgiving: true }),
langs: [bundledLanguages[language]],
themes: [
() => import("shiki/dist/themes/github-light.mjs"),
() => import("shiki/dist/themes/github-dark.mjs"),
],
});
highlighterCache.set(language, highlighter);
return highlighter;
};
const createPlainResult = (code: string): HighlightResult => ({
bg: "transparent",
fg: "inherit",
tokens: code
.split("\n")
.map((line) => (line === "" ? [] : [{ color: "inherit", content: line }])),
});
const code: CodeHighlighterPlugin = {
getSupportedLanguages: () => [...supportedLanguages],
getThemes: () => defaultThemes,
highlight: ({ code: source, language, themes }, callback) => {
const normalizedLanguage = normalizeLanguage(language);
if (!normalizedLanguage) {
return createPlainResult(source);
}
const cacheKey = getCacheKey(source, normalizedLanguage, themes);
const cached = tokenCache.get(cacheKey);
if (cached) {
return cached;
}
if (callback) {
if (!subscribers.has(cacheKey)) {
subscribers.set(cacheKey, new Set());
}
subscribers.get(cacheKey)?.add(callback);
}
getHighlighter(normalizedLanguage)
.then((highlighter) => {
const lightTheme = getThemeName(themes[0]);
const darkTheme = getThemeName(themes[1]);
const result = highlighter.codeToTokens(source, {
lang: normalizedLanguage,
themes: {
dark: darkTheme,
light: lightTheme,
},
});
tokenCache.set(cacheKey, result);
const cacheSubscribers = subscribers.get(cacheKey);
if (cacheSubscribers) {
for (const subscriber of cacheSubscribers) {
subscriber(result);
}
subscribers.delete(cacheKey);
}
})
.catch((error) => {
console.error("[Streamdown Code] Failed to highlight code:", error);
subscribers.delete(cacheKey);
});
return null;
},
name: "shiki",
supportsLanguage: (language) => normalizeLanguage(language) !== null,
type: "code-highlighter",
};
type MermaidConfig = NonNullable<Parameters<DiagramPlugin["getMermaid"]>[0]>;
type MermaidModule = typeof import("@streamdown/mermaid");
const defaultMermaidConfig = {
fontFamily: "monospace",
securityLevel: "strict",
startOnLoad: false,
suppressErrorRendering: true,
theme: "default",
} satisfies MermaidConfig;
let mermaidModulePromise: Promise<MermaidModule> | null = null;
let mermaidConfig: MermaidConfig = defaultMermaidConfig;
let pendingMermaidInitialize = true;
const getMermaidModule = () => {
if (!mermaidModulePromise) {
mermaidModulePromise = import("@streamdown/mermaid");
}
return mermaidModulePromise;
};
const lazyMermaid: DiagramPlugin = {
getMermaid: (config) => {
if (config) {
mermaidConfig = {
...defaultMermaidConfig,
...mermaidConfig,
...config,
};
pendingMermaidInitialize = true;
}
return {
initialize: (nextConfig) => {
mermaidConfig = {
...defaultMermaidConfig,
...nextConfig,
};
pendingMermaidInitialize = true;
void getMermaidModule().then(({ mermaid }) => {
mermaid.getMermaid().initialize(mermaidConfig);
pendingMermaidInitialize = false;
});
},
render: async (id, source) => {
const { mermaid } = await getMermaidModule();
const mermaidInstance = mermaid.getMermaid();
if (pendingMermaidInitialize) {
mermaidInstance.initialize(mermaidConfig);
pendingMermaidInitialize = false;
}
return mermaidInstance.render(id, source);
},
};
},
language: "mermaid",
name: "mermaid",
type: "diagram",
};
export const streamdownPlugins = {
cjk,
code,
math,
mermaid: lazyMermaid,
} satisfies PluginConfig;
+46
View File
@@ -3,6 +3,39 @@ import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react-swc";
import { defineConfig } from "vite";
const nodeModuleChunk = (moduleId: string) => {
if (!moduleId.includes("node_modules")) {
return null;
}
if (/[\\/]node_modules[\\/](shiki|@shikijs|mermaid)[\\/]/.test(moduleId)) {
return null;
}
if (/[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/.test(moduleId)) {
return "vendor-react";
}
if (
/[\\/]node_modules[\\/](@base-ui|@radix-ui|cmdk|lucide-react|sonner)[\\/]/.test(
moduleId,
)
) {
return "vendor-ui";
}
if (/[\\/]node_modules[\\/](@xyflow|recharts|d3-)[\\/]/.test(moduleId)) {
return "vendor-visualization";
}
if (/[\\/]node_modules[\\/](ai|tokenlens)[\\/]/.test(moduleId)) {
return "vendor-ai";
}
const packageMatch = moduleId
.split(/[\\/]node_modules[\\/]/)
.pop()
?.match(/^(@[^\\/]+[\\/][^\\/]+|[^\\/]+)/);
const packageName = packageMatch?.[1]?.replace(/[\\/@]/g, "-");
return packageName ? `vendor-${packageName}` : "vendor";
};
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
@@ -25,5 +58,18 @@ export default defineConfig({
outDir: "../../dist/webview",
emptyOutDir: true,
cssMinify: "esbuild",
chunkSizeWarningLimit: 800,
rolldownOptions: {
output: {
codeSplitting: {
groups: [
{
name: nodeModuleChunk,
test: /[\\/]node_modules[\\/]/,
},
],
},
},
},
},
});
+1 -2
View File
@@ -19,7 +19,7 @@
},
"apps/cli": {
"name": "@cline/cli",
"version": "3.0.17",
"version": "3.0.20",
"bin": {
"cline": "src/index.ts",
},
@@ -78,7 +78,6 @@
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@rive-app/react-webgl2": "^4.27.2",
"@streamdown/cjk": "^1.0.3",
"@streamdown/code": "^1.1.1",
"@streamdown/math": "^1.0.2",
"@streamdown/mermaid": "^1.0.2",
"@tailwindcss/vite": "^4.2.1",