From b384db79dfcfeccc19fb323d79a6296fab74f2b7 Mon Sep 17 00:00:00 2001 From: Nathan Beddoe Date: Sat, 12 Sep 2026 17:35:11 +0200 Subject: [PATCH] perf(chat): lazy-load scoped syntax highlighting --- package.json | 3 + patches/octane-kumo@0.0.0.patch | 477 ++++++++++++++++++++++++++++++++ pnpm-lock.yaml | 14 +- pnpm-workspace.yaml | 1 + src/components/chat-message.tsx | 5 +- src/components/chat-syntax.tsx | 58 ++++ src/routes/Conversation.tsx | 51 ++-- tests/chat-ui.test.mjs | 71 +++++ tests/fixtures/chat-worker.ts | 33 ++- 9 files changed, 679 insertions(+), 34 deletions(-) create mode 100644 patches/octane-kumo@0.0.0.patch create mode 100644 src/components/chat-syntax.tsx diff --git a/package.json b/package.json index e90eb58..8b3a4bc 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,8 @@ "@octanejs/tanstack-query": "0.1.52", "@octanejs/tanstack-router": "^0.1.52", "@octanejs/vite-plugin": "^0.1.52", + "@shikijs/langs": "4.0.0", + "@shikijs/themes": "4.0.0", "agents": "0.22.0", "ai": "7.0.93", "cron-schedule": "6.0.0", @@ -99,6 +101,7 @@ "marked": "18.0.11", "octane": "^0.2.2", "octane-kumo": "github:NathanBeddoeWebDev/octane-kumo#b86a46a4ed720eda9571d8940caa6f5ae6644fe3&path:/packages/octane-kumo", + "shiki": "4.0.0", "workers-ai-provider": "4.0.0", "zod": "4.4.3" }, diff --git a/patches/octane-kumo@0.0.0.patch b/patches/octane-kumo@0.0.0.patch new file mode 100644 index 0000000..ff57dab --- /dev/null +++ b/patches/octane-kumo@0.0.0.patch @@ -0,0 +1,477 @@ +diff --git a/package.json b/package.json +index 8df058337b9a9e9783ac21211586be5eb4c47c7b..91a429519805bbeb702cc90b337a5d95687f1354 100644 +--- a/package.json ++++ b/package.json +@@ -43,6 +43,11 @@ + "import": "./src/code/index.ts", + "default": "./src/code/index.ts" + }, ++ "./code/custom": { ++ "types": "./src/code/custom.ts", ++ "import": "./src/code/custom.ts", ++ "default": "./src/code/custom.ts" ++ }, + "./code/server": { + "types": "./src/code/server.tsx", + "import": "./src/code/server.tsx", +diff --git a/src/code/code-highlighted.tsx b/src/code/code-highlighted.tsx +index a7c9158d9369a3f7af9034d40ef2a64f12e15cde..1400b8fdc5cc7d8f96d683c9e83c0352219a39b8 100644 +--- a/src/code/code-highlighted.tsx ++++ b/src/code/code-highlighted.tsx +@@ -3,7 +3,7 @@ import { useState, useEffect, useMemo, useRef } from "octane"; + import { cn } from "../utils/cn"; + import { Button } from "../components/button"; + import { copyText } from "../utils/copy-text"; +-import { useShikiHighlighter } from "./provider"; ++import { useShikiHighlighter } from "./context"; + import type { CodeHighlightedProps } from "./types"; + + export function CodeHighlighted({ +diff --git asrc/code/context.tsx b/src/code/context.tsx +new file mode 100644 +index 0000000000000000000000000000000000000000..d471e44ec03b8fee8fb180ae5d003f580f7c67ba +--- /dev/null ++++ b/src/code/context.tsx +@@ -0,0 +1,61 @@ ++/** @jsxImportSource octane */ ++import { createContext, useCallback, useContext } from "octane"; ++import type { HighlighterCore } from "shiki/core"; ++import { CODE_THEMES, normalizeLanguage } from "./shared"; ++import type { ++ CodeHighlightedLabels, ++ SupportedLanguage, ++ UseShikiHighlighterResult, ++} from "./types"; ++ ++export interface HighlighterState { ++ highlighter: HighlighterCore | null; ++ isLoading: boolean; ++ error: Error | null; ++ languages: SupportedLanguage[]; ++} ++ ++export const ShikiContext = createContext< ++ (HighlighterState & { labels: CodeHighlightedLabels }) | null ++>(null); ++ ++export function useShikiHighlighter(): UseShikiHighlighterResult { ++ const context = useContext(ShikiContext); ++ if (!context) ++ throw new Error( ++ "useShikiHighlighter must be used within a ShikiProvider from 'octane-kumo/code'.", ++ ); ++ const { highlighter, languages, isLoading, error, labels } = context; ++ const highlight = useCallback( ++ (code: string, lang: string): string | null => { ++ if (!highlighter) return null; ++ const normalized = normalizeLanguage(lang); ++ if (!normalized || !languages.includes(normalized)) { ++ console.warn( ++ `[Kumo CodeHighlighted] Language "${lang}" is not in the ShikiProvider's languages list. Rendering as plain text.`, ++ ); ++ return null; ++ } ++ try { ++ return highlighter.codeToHtml(code, { ++ lang: normalized, ++ themes: CODE_THEMES, ++ }); ++ } catch (error) { ++ console.warn( ++ `[Kumo CodeHighlighted] Failed to highlight code with language "${lang}":`, ++ error, ++ ); ++ return null; ++ } ++ }, ++ [highlighter, languages], ++ ); ++ return { ++ highlight, ++ isLoading, ++ isReady: !isLoading && highlighter !== null, ++ error, ++ labels, ++ }; ++} +diff --git asrc/code/custom-provider.tsx b/src/code/custom-provider.tsx +new file mode 100644 +index 0000000000000000000000000000000000000000..f48498fa8ccd0b7886a37533c35d2706ec2b5c76 +--- /dev/null ++++ b/src/code/custom-provider.tsx +@@ -0,0 +1,86 @@ ++/** @jsxImportSource octane */ ++import { useEffect, useMemo, useState } from "octane"; ++import type { HighlighterCore } from "shiki/core"; ++import { HighlighterState, ShikiContext } from "./context"; ++import { normalizeLanguage } from "./shared"; ++import type { ++ CustomShikiProviderProps, ++ SupportedLanguage, ++} from "./types"; ++ ++export function CustomShikiProvider({ ++ createHighlighter, ++ languages, ++ labels, ++ children, ++}: CustomShikiProviderProps) { ++ const [state, setState] = useState({ ++ highlighter: null, ++ isLoading: true, ++ error: null, ++ languages: [], ++ }); ++ const languageKey = [ ++ ...new Set( ++ languages ++ .map(normalizeLanguage) ++ .filter((lang): lang is SupportedLanguage => lang !== null), ++ ), ++ ] ++ .sort() ++ .join(","); ++ useEffect(() => { ++ let cancelled = false; ++ let owned: HighlighterCore | null = null; ++ const validLanguages = languageKey ++ ? (languageKey.split(",") as SupportedLanguage[]) ++ : []; ++ setState({ ++ highlighter: null, ++ isLoading: true, ++ error: null, ++ languages: [], ++ }); ++ void createHighlighter(validLanguages).then( ++ (highlighter) => { ++ if (cancelled) { ++ highlighter.dispose(); ++ return; ++ } ++ owned = highlighter; ++ setState({ ++ highlighter, ++ isLoading: false, ++ error: null, ++ languages: validLanguages, ++ }); ++ }, ++ (error: unknown) => { ++ if (!cancelled) ++ setState({ ++ highlighter: null, ++ isLoading: false, ++ error: ++ error instanceof Error ++ ? error ++ : new Error("Failed to load Shiki"), ++ languages: [], ++ }); ++ }, ++ ); ++ return () => { ++ cancelled = true; ++ owned?.dispose(); ++ }; ++ }, [createHighlighter, languageKey]); ++ const value = useMemo( ++ () => ({ ++ ...state, ++ labels: { copy: "Copy", copied: "Copied!", ...labels }, ++ }), ++ [state, labels], ++ ); ++ return ( ++ {children} ++ ); ++} +diff --git asrc/code/custom.ts b/src/code/custom.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..bc588ac3ab545b7e44fb1fba62c82d4f75445956 +--- /dev/null ++++ b/src/code/custom.ts +@@ -0,0 +1,13 @@ ++export { CustomShikiProvider as ShikiProvider } from "./custom-provider"; ++export { CodeHighlighted } from "./code-highlighted"; ++export { normalizeLanguage } from "./shared"; ++export { LANGUAGE_ALIASES } from "./types"; ++export type { ++ CodeHighlightedLabels, ++ CodeHighlightedProps, ++ CustomShikiProviderProps, ++ LanguageAlias, ++ LanguageInput, ++ SupportedLanguage, ++ UseShikiHighlighterResult, ++} from "./types"; +diff --git a/src/code/highlighter.ts b/src/code/highlighter.ts +index 7d859a4c2e5c0626c1145b2c87062e10f57e1972..958f5f704e8e2c142e794db91031e14027263e60 100644 +--- a/src/code/highlighter.ts ++++ b/src/code/highlighter.ts +@@ -1,7 +1,5 @@ + import type { HighlighterCore } from "shiki/core"; + import { +- LANGUAGE_ALIASES, +- type LanguageAlias, + type ShikiEngine, + type SupportedLanguage, + } from "./types"; +@@ -27,14 +25,7 @@ const BUNDLED_LANGS = { + toml: () => import("@shikijs/langs/toml"), + }; + +-export const CODE_THEMES = { light: "github-light", dark: "vesper" } as const; +- +-export function normalizeLanguage(lang: string): SupportedLanguage | null { +- if (Object.hasOwn(BUNDLED_LANGS, lang)) return lang as SupportedLanguage; +- if (Object.hasOwn(LANGUAGE_ALIASES, lang)) +- return LANGUAGE_ALIASES[lang as LanguageAlias]; +- return null; +-} ++export { CODE_THEMES, normalizeLanguage } from "./shared"; + + /** Shared lazy engine initialization for client providers and server utilities. */ + export async function createHighlighter( +diff --git a/src/code/index.ts b/src/code/index.ts +index 987a5ad486a694fc8891339dd497893e4964602c..f314061c77f7b882c81b347128d83311cbcdec22 100644 +--- a/src/code/index.ts ++++ b/src/code/index.ts +@@ -1,5 +1,5 @@ + export { ShikiProvider, useShikiHighlighter } from "./provider"; +-export { normalizeLanguage } from "./highlighter"; ++export { normalizeLanguage } from "./shared"; + export { CodeHighlighted } from "./code-highlighted"; + export { LANGUAGE_ALIASES } from "./types"; + export type { +diff --git a/src/code/provider.tsx b/src/code/provider.tsx +index e63d1d1dedbe667bcc0a6ed62839f4bdad40b3b4..e6cc41119fd0565eeabc40ccd050fdbe103fc827 100644 +--- a/src/code/provider.tsx ++++ b/src/code/provider.tsx +@@ -1,149 +1,23 @@ + /** @jsxImportSource octane */ +-import { +- createContext, +- useState, +- useEffect, +- useMemo, +- useContext, +- useCallback, +-} from "octane"; +-import type { HighlighterCore } from "shiki/core"; +-import { +- createHighlighter, +- normalizeLanguage, +- CODE_THEMES, +-} from "./highlighter"; +-import type { +- CodeHighlightedLabels, +- ShikiProviderProps, +- SupportedLanguage, +- UseShikiHighlighterResult, +-} from "./types"; ++import { useCallback } from "octane"; ++import { createHighlighter } from "./highlighter"; ++import { CustomShikiProvider } from "./custom-provider"; ++import type { ShikiProviderProps } from "./types"; + +-interface HighlighterState { +- highlighter: HighlighterCore | null; +- isLoading: boolean; +- error: Error | null; +- languages: SupportedLanguage[]; +-} +-const ShikiContext = createContext< +- (HighlighterState & { labels: CodeHighlightedLabels }) | null +->(null); +- +-export function ShikiProvider({ +- engine, +- languages, +- labels, +- children, +-}: ShikiProviderProps) { +- const [state, setState] = useState({ +- highlighter: null, +- isLoading: true, +- error: null, +- languages: [], +- }); +- const languageKey = [ +- ...new Set( +- languages +- .map(normalizeLanguage) +- .filter((lang): lang is SupportedLanguage => lang !== null), +- ), +- ] +- .sort() +- .join(","); +- useEffect(() => { +- let cancelled = false; +- let owned: HighlighterCore | null = null; +- const validLanguages = languageKey +- ? (languageKey.split(",") as SupportedLanguage[]) +- : []; +- setState({ +- highlighter: null, +- isLoading: true, +- error: null, +- languages: [], +- }); +- void createHighlighter(engine, validLanguages).then( +- (highlighter) => { +- if (cancelled) { +- highlighter.dispose(); +- return; +- } +- owned = highlighter; +- setState({ +- highlighter, +- isLoading: false, +- error: null, +- languages: validLanguages, +- }); +- }, +- (error: unknown) => { +- if (!cancelled) +- setState({ +- highlighter: null, +- isLoading: false, +- error: +- error instanceof Error +- ? error +- : new Error("Failed to load Shiki"), +- languages: [], +- }); +- }, +- ); +- return () => { +- cancelled = true; +- owned?.dispose(); +- }; +- }, [engine, languageKey]); +- const value = useMemo( +- () => ({ +- ...state, +- labels: { copy: "Copy", copied: "Copied!", ...labels }, +- }), +- [state, labels], ++export function ShikiProvider({ engine, languages, labels, children }: ShikiProviderProps) { ++ const factory = useCallback( ++ (configuredLanguages) => createHighlighter(engine, configuredLanguages), ++ [engine], + ); + return ( +- {children} ++ ++ {children} ++ + ); + } + +-export function useShikiHighlighter(): UseShikiHighlighterResult { +- const context = useContext(ShikiContext); +- if (!context) +- throw new Error( +- "useShikiHighlighter must be used within a ShikiProvider from 'octane-kumo/code'.", +- ); +- const { highlighter, languages, isLoading, error, labels } = context; +- const highlight = useCallback( +- (code: string, lang: string): string | null => { +- if (!highlighter) return null; +- const normalized = normalizeLanguage(lang); +- if (!normalized || !languages.includes(normalized)) { +- console.warn( +- `[Kumo CodeHighlighted] Language "${lang}" is not in the ShikiProvider's languages list. Rendering as plain text.`, +- ); +- return null; +- } +- try { +- return highlighter.codeToHtml(code, { +- lang: normalized, +- themes: CODE_THEMES, +- }); +- } catch (error) { +- console.warn( +- `[Kumo CodeHighlighted] Failed to highlight code with language "${lang}":`, +- error, +- ); +- return null; +- } +- }, +- [highlighter, languages], +- ); +- return { +- highlight, +- isLoading, +- isReady: !isLoading && highlighter !== null, +- error, +- labels, +- }; +-} ++export { useShikiHighlighter } from "./context"; +diff --git asrc/code/shared.ts b/src/code/shared.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..9ec80064c7c5adfe1038eff23ec8d1abc28831d5 +--- /dev/null ++++ b/src/code/shared.ts +@@ -0,0 +1,36 @@ ++import { ++ LANGUAGE_ALIASES, ++ type LanguageAlias, ++ type SupportedLanguage, ++} from "./types"; ++ ++const SUPPORTED_LANGUAGES = new Set([ ++ "javascript", ++ "typescript", ++ "jsx", ++ "tsx", ++ "json", ++ "jsonc", ++ "html", ++ "css", ++ "python", ++ "yaml", ++ "markdown", ++ "graphql", ++ "sql", ++ "bash", ++ "shell", ++ "diff", ++ "hcl", ++ "toml", ++]); ++ ++export const CODE_THEMES = { light: "github-light", dark: "vesper" } as const; ++ ++export function normalizeLanguage(lang: string): SupportedLanguage | null { ++ if (SUPPORTED_LANGUAGES.has(lang as SupportedLanguage)) ++ return lang as SupportedLanguage; ++ if (Object.hasOwn(LANGUAGE_ALIASES, lang)) ++ return LANGUAGE_ALIASES[lang as LanguageAlias]; ++ return null; ++} +diff --git a/src/code/types.ts b/src/code/types.ts +index f23acd84c915ea40a5ef51a9ae3b6720892e3182..eb6ee40b6a0ac6ed6487e0ae7394d48a16f49833 100644 +--- a/src/code/types.ts ++++ b/src/code/types.ts +@@ -49,6 +49,14 @@ export interface ShikiProviderProps { + labels?: CodeHighlightedLabels; + children: OctaneNode; + } ++export interface CustomShikiProviderProps { ++ createHighlighter: ( ++ languages: SupportedLanguage[], ++ ) => Promise; ++ languages: LanguageInput[]; ++ labels?: CodeHighlightedLabels; ++ children: OctaneNode; ++} + export interface UseShikiHighlighterResult { + highlight: ( + code: string, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db18229..6c19ab6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,7 @@ settings: patchedDependencies: '@cloudflare/think@0.17.0': 5fa7a2abb30f762a7622c66fbb8fac42c59ecc8ea2752cb366672cb7a66e6096 + octane-kumo@0.0.0: 1aefd50b6aeeca4ebe9b7012aa03db651e5a1ec6d1343feebdfcc1f4c201de30 workers-ai-provider@4.0.0: 5f0670c81673a4c40038ff1cf2c3d0534a53f050acbcc27e0b89c00c3ead9864 importers: @@ -155,6 +156,12 @@ importers: '@octanejs/vite-plugin': specifier: ^0.1.52 version: 0.1.52(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)) + '@shikijs/langs': + specifier: 4.0.0 + version: 4.0.0 + '@shikijs/themes': + specifier: 4.0.0 + version: 4.0.0 agents: specifier: 0.22.0 version: 0.22.0(@babel/core@8.0.1)(@babel/runtime@7.29.7)(@cloudflare/codemode@0.5.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.93(zod@4.4.3))(zod@4.4.3))(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(@modelcontextprotocol/server@2.0.0)(ai@7.0.93(zod@4.4.3))(chat@4.39.0(ai@7.0.93(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(just-bash@3.4.2(supports-color@10.2.2))(react@19.2.8)(rolldown@1.2.7)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0))(zod@4.4.3) @@ -178,7 +185,10 @@ importers: version: 0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)) octane-kumo: specifier: github:NathanBeddoeWebDev/octane-kumo#b86a46a4ed720eda9571d8940caa6f5ae6644fe3&path:/packages/octane-kumo - version: https://codeload.github.com/NathanBeddoeWebDev/octane-kumo/tar.gz/b86a46a4ed720eda9571d8940caa6f5ae6644fe3#path:/packages/octane-kumo(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)))(react@19.2.8) + version: https://codeload.github.com/NathanBeddoeWebDev/octane-kumo/tar.gz/b86a46a4ed720eda9571d8940caa6f5ae6644fe3#path:/packages/octane-kumo(patch_hash=1aefd50b6aeeca4ebe9b7012aa03db651e5a1ec6d1343feebdfcc1f4c201de30)(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)))(react@19.2.8) + shiki: + specifier: 4.0.0 + version: 4.0.0 workers-ai-provider: specifier: 4.0.0 version: 4.0.0(patch_hash=5f0670c81673a4c40038ff1cf2c3d0534a53f050acbcc27e0b89c00c3ead9864)(@ai-sdk/anthropic@4.0.49(zod@4.4.3))(@ai-sdk/openai@4.0.59(zod@4.4.3))(@ai-sdk/provider@4.0.10)(ai@7.0.93(zod@4.4.3)) @@ -5400,7 +5410,7 @@ snapshots: obug@2.1.4: {} - octane-kumo@https://codeload.github.com/NathanBeddoeWebDev/octane-kumo/tar.gz/b86a46a4ed720eda9571d8940caa6f5ae6644fe3#path:/packages/octane-kumo(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)))(react@19.2.8): + octane-kumo@https://codeload.github.com/NathanBeddoeWebDev/octane-kumo/tar.gz/b86a46a4ed720eda9571d8940caa6f5ae6644fe3#path:/packages/octane-kumo(patch_hash=1aefd50b6aeeca4ebe9b7012aa03db651e5a1ec6d1343feebdfcc1f4c201de30)(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)))(react@19.2.8): dependencies: '@octanejs/aria': 0.0.45(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)))(react@19.2.8) '@octanejs/base-ui': 0.1.49(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0))) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b611d37..442f261 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,4 +12,5 @@ minimumReleaseAgeExclude: - playwright@1.63.0 patchedDependencies: "@cloudflare/think@0.17.0": patches/@cloudflare__think@0.17.0.patch + octane-kumo@0.0.0: patches/octane-kumo@0.0.0.patch workers-ai-provider@4.0.0: patches/workers-ai-provider@4.0.0.patch diff --git a/src/components/chat-message.tsx b/src/components/chat-message.tsx index 2f66c75..c56a01f 100644 --- a/src/components/chat-message.tsx +++ b/src/components/chat-message.tsx @@ -6,7 +6,7 @@ import { messageAttachments } from "../../shared/attachments"; import { useMemo, useState, useEffect, useRef, type OctaneNode } from "octane"; import { Lexer, type Token, type Tokens } from "marked"; import { decodeHTML } from "entities"; -import { CodeHighlighted } from "octane-kumo/code"; +import { CodeHighlighted } from "octane-kumo/code/custom"; import { Button, LinkButton } from "octane-kumo/components/button"; import { Banner } from "octane-kumo/components/banner"; import { Loader } from "octane-kumo/components/loader"; @@ -237,6 +237,9 @@ function renderTokens( } }); } +export function hasCodeBlock(text: string) { + return Lexer.lex(text, { gfm: true }).some((token) => token.type === "code"); +} function Markdown({ text, streaming }: { text: string; streaming: boolean }) { const tokens = useMemo(() => Lexer.lex(text, { gfm: true }), [text]); return
{renderTokens(tokens, streaming)}
; diff --git a/src/components/chat-syntax.tsx b/src/components/chat-syntax.tsx new file mode 100644 index 0000000..92e38d1 --- /dev/null +++ b/src/components/chat-syntax.tsx @@ -0,0 +1,58 @@ +import type { OctaneNode } from "octane"; +import { ShikiProvider, type SupportedLanguage } from "octane-kumo/code/custom"; + +export const chatSyntaxLanguages = [ + "javascript", + "typescript", + "json", + "bash", + "python", + "html", + "css", + "sql", +] as const satisfies readonly SupportedLanguage[]; + +const languageLoaders = { + javascript: () => import("@shikijs/langs/javascript"), + typescript: () => import("@shikijs/langs/typescript"), + json: () => import("@shikijs/langs/json"), + bash: () => import("@shikijs/langs/bash"), + python: () => import("@shikijs/langs/python"), + html: () => import("@shikijs/langs/html"), + css: () => import("@shikijs/langs/css"), + sql: () => import("@shikijs/langs/sql"), +} as const; + +async function createChatHighlighter(languages: SupportedLanguage[]) { + const [{ createHighlighterCore }, engine, light, dark, ...grammars] = + await Promise.all([ + import("shiki/core"), + import("shiki/engine/javascript"), + import("@shikijs/themes/github-light"), + import("@shikijs/themes/vesper"), + ...languages.map((language) => { + const load = languageLoaders[language as keyof typeof languageLoaders]; + if (!load) + throw new Error( + `Unsupported chat syntax language requested: ${language}`, + ); + return load(); + }), + ]); + return createHighlighterCore({ + engine: engine.createJavaScriptRegexEngine(), + themes: [light.default, dark.default], + langs: grammars.map((grammar) => grammar.default), + }); +} + +export function ChatSyntaxProvider({ children }: { children: OctaneNode }) { + return ( + + {children} + + ); +} diff --git a/src/routes/Conversation.tsx b/src/routes/Conversation.tsx index 3451951..f948bab 100644 --- a/src/routes/Conversation.tsx +++ b/src/routes/Conversation.tsx @@ -12,11 +12,11 @@ import { useEffect, useRef, useState, useSyncExternalStore } from "octane"; import { Button } from "octane-kumo/components/button"; import { InputArea } from "octane-kumo/components/input"; import { Loader } from "octane-kumo/components/loader"; -import { ShikiProvider } from "octane-kumo/code"; import { useShellSession } from "../runtime/shell-session"; import { ConversationSession } from "../runtime/conversation-session"; import { CapabilityApprovals } from "../components/capability-approvals"; -import { ChatMessage } from "../components/chat-message"; +import { ChatSyntaxProvider } from "../components/chat-syntax"; +import { ChatMessage, hasCodeBlock } from "../components/chat-message"; import { ChatModelPicker } from "../components/chat-model-picker"; import { ModelEffortSelect } from "../components/model-effort-select"; import { @@ -189,6 +189,25 @@ function ConversationContent({ const earlier = Math.max(0, view.messages.length - count); const terminal = view.connection === "signed-out" || view.connection === "missing"; + const visibleMessages = view.messages.slice(earlier); + const streamingMessageId = busy ? view.messages.at(-1)?.id : undefined; + const highlightCode = visibleMessages.some( + (message) => + message.id !== streamingMessageId && + message.role !== "user" && + message.parts.some( + (part) => part.type === "text" && hasCodeBlock(part.text), + ), + ); + const messageRows = visibleMessages.map((message) => ( + + )); return (
@@ -299,29 +318,11 @@ function ConversationContent({

) : null} - - {view.messages.slice(earlier).map((message) => ( - - ))} - + {highlightCode ? ( + {messageRows} + ) : ( + messageRows + )} {!terminal && ( )} diff --git a/tests/chat-ui.test.mjs b/tests/chat-ui.test.mjs index 7bdb240..ae17d8d 100644 --- a/tests/chat-ui.test.mjs +++ b/tests/chat-ui.test.mjs @@ -271,6 +271,13 @@ chatLifecycleTest( }, ); const frames = []; + const syntaxAssetRequests = new Set(); + page.on("request", (request) => { + const asset = new URL(request.url()).pathname.match( + /\/assets\/(core|engine-javascript|github-light|vesper|javascript|typescript|json|bash|python|html|css|sql)-[^/]+\.js$/, + )?.[1]; + if (asset) syntaxAssetRequests.add(asset); + }); page.on("websocket", (socket) => { socket.on("framereceived", ({ payload }) => { try { @@ -1108,6 +1115,19 @@ chatLifecycleTest( ); }, ); + await run( + "plain conversations do not download or initialize syntax highlighting", + async () => { + const conversation = await make("Plain syntax"); + syntaxAssetRequests.clear(); + await open(conversation.id); + await send("fast"); + await ready(); + assert.equal(await rows.last().locator(".chat-code").count(), 0); + assert.equal(await page.locator(".kumo-shiki").count(), 0); + assert.deepEqual([...syntaxAssetRequests], []); + }, + ); await run( "native extension activity provenance persists and fits mobile chat", () => @@ -1135,6 +1155,57 @@ chatLifecycleTest( await checkSkillScriptOutput({ page, rows, ready, screenshots }); }, ); + await run( + "configured languages highlight after streaming and unsupported code remains plain", + async () => { + const conversation = await make("Syntax support"); + await open(conversation.id); + syntaxAssetRequests.clear(); + await send("syntax"); + await ready(); + const code = rows.last().locator(".chat-code"); + await until( + () => code.locator(".kumo-shiki").count(), + (count) => count === 8, + "configured syntax highlighting", + ); + assert.equal(await code.count(), 9); + assert.equal( + await code + .filter({ hasText: "rust" }) + .locator(".kumo-shiki") + .count(), + 0, + ); + assert.deepEqual([...syntaxAssetRequests].sort(), [ + "bash", + "core", + "css", + "engine-javascript", + "github-light", + "html", + "javascript", + "json", + "python", + "sql", + "typescript", + "vesper", + ]); + await send("syntax-stream"); + const streaming = rows.last(); + await until( + () => streaming.innerText(), + (text) => text.includes("const streaming = true;"), + "streaming code", + ); + assert.equal( + await streaming.locator(".chat-code .kumo-shiki").count(), + 0, + ); + await ready(); + await streaming.locator(".chat-code .kumo-shiki").waitFor(); + }, + ); await run( "markdown, highlighted code, sources and tool status banners", async () => { diff --git a/tests/fixtures/chat-worker.ts b/tests/fixtures/chat-worker.ts index bed3713..8c2784d 100644 --- a/tests/fixtures/chat-worker.ts +++ b/tests/fixtures/chat-worker.ts @@ -27,6 +27,20 @@ export class PersonalAgent extends BasePersonalAgent { } const rich = `## Research result\n\nThis is **clear** and *readable* with \`inline code\`.\n\n- First item\n- Second item\n\n> Quoted evidence\n\n\`\`\`javascript\nconst answer = "";\nconsole.log(answer);\n\`\`\`\n\n| Topic | Result |\n| --- | --- |\n| Native | Working |\n\n[Documentation](https://developers.cloudflare.com/agents/)\n\n\n\n[unsafe](javascript:alert(1)) ![remote](https://evil.invalid/pixel) [data](data:text/html,bad) [relative](//evil.invalid/)\n`; +const syntax = [ + ["javascript", "const example = true;"], + ["typescript", "const example: boolean = true;"], + ["json", '{ "example": true }'], + ["bash", "echo example"], + ["python", 'print("example")'], + ["html", "

example

"], + ["css", ".example { color: red; }"], + ["sql", "SELECT example FROM examples;"], + ["rust", "let example = true;"], +] + .map(([language, code]) => `\`\`\`${language}\n${code}\n\`\`\``) + .join("\n\n"); +const streamingSyntax = "\`\`\`javascript\nconst streaming = true;\n\`\`\`"; type Chunk = Awaited< ReturnType @@ -125,11 +139,18 @@ export class Conversation extends BaseConversation { ? ["Resource saved."] : text === "rich" ? [rich] - : [ - "Starting ", - ...Array.from({ length: 20 }, (_, i) => `${text}-${i} `), - "finished.", - ]; + : text === "syntax" + ? [syntax] + : text === "syntax-stream" + ? [streamingSyntax] + : [ + "Starting ", + ...Array.from( + { length: 20 }, + (_, i) => `${text}-${i} `, + ), + "finished.", + ]; return { stream: new ReadableStream({ async start(controller) { @@ -156,7 +177,7 @@ export class Conversation extends BaseConversation { return; } emit({ type: "text-delta", id: "text", delta: token }); - if (text !== "rich" && text !== "fast") + if (text !== "rich" && text !== "fast" && text !== "syntax") await new Promise((resolve) => { const timer = setTimeout( resolve, -- 2.51.2