From 5b21346c10e3a73ded6bc1fa9db492db9ebae088 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 7 Oct 2025 22:14:47 -0700 Subject: [PATCH 1/9] new streamdown impl... shiki, custom styling fixes, etc --- frontend/app/aipanel/aimessage.tsx | 26 +-- frontend/app/element/streamdown.tsx | 245 ++++++++++++++++++++++++++++ frontend/tailwindsetup.css | 1 + package-lock.json | 65 ++++---- package.json | 1 + tsconfig.json | 2 +- 6 files changed, 284 insertions(+), 56 deletions(-) create mode 100644 frontend/app/element/streamdown.tsx diff --git a/frontend/app/aipanel/aimessage.tsx b/frontend/app/aipanel/aimessage.tsx index 34c4773f4b..693ccce112 100644 --- a/frontend/app/aipanel/aimessage.tsx +++ b/frontend/app/aipanel/aimessage.tsx @@ -1,9 +1,9 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { WaveStreamdown } from "@/app/element/streamdown"; import { cn } from "@/util/util"; import { memo } from "react"; -import { Streamdown } from "streamdown"; import { getFileIcon } from "./ai-utils"; import { WaveUIMessage, WaveUIMessagePart } from "./aitypes"; @@ -78,25 +78,7 @@ const AIMessagePart = memo(({ part, role, isStreaming }: AIMessagePartProps) => if (role === "user") { return
{content}
; } else { - return ( - - {content} - - ); + return ; } } @@ -139,9 +121,7 @@ export const AIMessage = memo(({ message, isStreaming }: AIMessageProps) => {
{showThinkingOnly ? ( diff --git a/frontend/app/element/streamdown.tsx b/frontend/app/element/streamdown.tsx new file mode 100644 index 0000000000..b0ddcbffdd --- /dev/null +++ b/frontend/app/element/streamdown.tsx @@ -0,0 +1,245 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { CopyButton } from "@/app/element/copybutton"; +import { IconButton } from "@/app/element/iconbutton"; +import { cn } from "@/util/util"; +import { useEffect, useRef, useState } from "react"; +import { codeToHtml } from "shiki/bundle/web"; +import { Streamdown } from "streamdown"; + +const ShikiTheme = "github-dark-high-contrast"; + +export function Code({ className = "", children }: { className?: string; children: React.ReactNode }) { + const [html, setHtml] = useState(null); + const codeRef = useRef(null); + + useEffect(() => { + let disposed = false; + + const raw = codeRef.current?.textContent ?? (typeof children === "string" ? children : ""); + + const m = className?.match(/language-([\w+-]+)/i); + const lang = m?.[1] || "text"; + + if (!raw || lang === "text") { + setHtml(null); + return; + } + + (async () => { + try { + const full = await codeToHtml(raw, { lang, theme: ShikiTheme }); + // strip outer
 wrapper quickly:
+                const start = full.indexOf("", start);
+                const end = full.lastIndexOf("");
+                const inner = start !== -1 && open !== -1 && end !== -1 ? full.slice(open + 1, end) : null;
+                if (!disposed) setHtml(inner);
+            } catch (e) {
+                if (!disposed) setHtml(null);
+                console.warn(`Shiki highlight failed for ${lang}`, e);
+            }
+        })();
+
+        return () => {
+            disposed = true;
+        };
+    }, [children, className]);
+
+    if (html) {
+        return (
+            
+        );
+    }
+
+    return (
+        
+            {children}
+        
+    );
+}
+
+type CodeBlockProps = {
+    children: React.ReactNode;
+    onClickExecute?: (cmd: string) => void;
+};
+
+const CodeBlock = ({ children, onClickExecute }: CodeBlockProps) => {
+    const getTextContent = (children: any): string => {
+        if (typeof children === "string") {
+            return children;
+        } else if (Array.isArray(children)) {
+            return children.map(getTextContent).join("");
+        } else if (children.props && children.props.children) {
+            return getTextContent(children.props.children);
+        }
+        return "";
+    };
+
+    const handleCopy = async (e: React.MouseEvent) => {
+        let textToCopy = getTextContent(children);
+        textToCopy = textToCopy.replace(/\n$/, "");
+        await navigator.clipboard.writeText(textToCopy);
+    };
+
+    const handleExecute = (e: React.MouseEvent) => {
+        let textToCopy = getTextContent(children);
+        textToCopy = textToCopy.replace(/\n$/, "");
+        if (onClickExecute) {
+            onClickExecute(textToCopy);
+            return;
+        }
+    };
+
+    return (
+        
+            {children}
+            
+ + {onClickExecute && ( + + )} +
+
+ ); +}; + +function Collapsible({ title, children, defaultOpen = false }) { + const [isOpen, setIsOpen] = useState(defaultOpen); + + return ( +
+ + {isOpen &&
{children}
} +
+ ); +} + +interface WaveStreamdownProps { + text: string; + parseIncompleteMarkdown?: boolean; + className?: string; + onClickExecute?: (cmd: string) => void; +} + +export const WaveStreamdown = ({ text, parseIncompleteMarkdown, className, onClickExecute }: WaveStreamdownProps) => { + return ( + ) => ( + + ), + p: (props: React.HTMLAttributes) =>

, + h1: (props: React.HTMLAttributes) => ( +

+ ), + h2: (props: React.HTMLAttributes) => ( +

+ ), + h3: (props: React.HTMLAttributes) => ( +

+ ), + h4: (props: React.HTMLAttributes) => ( +

+ ), + h5: (props: React.HTMLAttributes) => ( +
+ ), + h6: (props: React.HTMLAttributes) => ( +
+ ), + table: (props: React.HTMLAttributes) => ( + + ), + thead: (props: React.HTMLAttributes) => ( + + ), + tbody: (props: React.HTMLAttributes) => , + tr: (props: React.HTMLAttributes) => ( + + ), + th: (props: React.HTMLAttributes) => ( +
+ ), + td: (props: React.HTMLAttributes) => ( + + ), + ul: (props: React.HTMLAttributes) => ( +