From f6afe0d86415d7236f93255a03404dd977f64796 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 17 May 2026 16:01:08 +0800 Subject: [PATCH] feat(app): syntax highlighting and copy button for chat code blocks (#1062) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Lezer-based highlighting to fenced code blocks in assistant messages, with a hover/tap copy button. Colors move into the theme as `theme.colors.syntax`, so the chat blocks, file preview, and diff viewer share one StyleSheet path — token color updates flow through the Unistyles native ShadowRegistry with no React re-renders on the chat hot path. --- packages/app/src/components/file-pane.tsx | 52 +--- .../src/components/highlighted-code-block.tsx | 269 ++++++++++++++++++ packages/app/src/components/message.tsx | 17 +- packages/app/src/git/diff-pane.tsx | 36 +-- .../app/src/styles/syntax-token-styles.ts | 40 +++ packages/app/src/styles/theme.ts | 4 + .../types/react-native-markdown-display.d.ts | 11 + 7 files changed, 347 insertions(+), 82 deletions(-) create mode 100644 packages/app/src/components/highlighted-code-block.tsx create mode 100644 packages/app/src/styles/syntax-token-styles.ts create mode 100644 packages/app/src/types/react-native-markdown-display.d.ts diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index 9b7fdbe10..21a068d6c 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -15,13 +15,8 @@ import { Fonts } from "@/constants/theme"; import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; -import { - highlightCode, - darkHighlightColors, - lightHighlightColors, - type HighlightToken, - type HighlightStyle, -} from "@getpaseo/highlight"; +import { highlightCode, type HighlightToken } from "@getpaseo/highlight"; +import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; import { lineNumberGutterWidth } from "@/components/code-insets"; import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode"; import { isWeb } from "@/constants/platform"; @@ -36,8 +31,6 @@ interface CodeLineProps { tokens: HighlightToken[]; lineNumber: number; gutterWidth: number; - colorMap: Record; - baseColor: string; } interface FilePreviewBodyProps { @@ -99,18 +92,8 @@ async function createFilePanePreview(file: FileReadResult | null): Promise<{ }; } -const CodeLine = React.memo(function CodeLine({ - tokens, - lineNumber, - gutterWidth, - colorMap, - baseColor, -}: CodeLineProps) { +const CodeLine = React.memo(function CodeLine({ tokens, lineNumber, gutterWidth }: CodeLineProps) { const gutterStyle = useMemo(() => [codeLineStyles.gutter, { width: gutterWidth }], [gutterWidth]); - const gutterTextStyle = useMemo( - () => [codeLineStyles.gutterText, { color: baseColor }], - [baseColor], - ); const keyedTokens = useMemo( () => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })), [tokens], @@ -118,17 +101,13 @@ const CodeLine = React.memo(function CodeLine({ return ( - + {String(lineNumber)} {keyedTokens.map(({ key, token }) => ( - + ))} @@ -136,13 +115,11 @@ const CodeLine = React.memo(function CodeLine({ }); interface CodeLineTokenProps { - color: string; - text: string; + token: HighlightToken; } -function CodeLineToken({ color, text }: CodeLineTokenProps) { - const style = useMemo(() => ({ color }), [color]); - return {text}; +function CodeLineToken({ token }: CodeLineTokenProps) { + return {token.text}; } const codeLineStyles = StyleSheet.create((theme) => ({ @@ -155,6 +132,7 @@ const codeLineStyles = StyleSheet.create((theme) => ({ flexShrink: 0, }, gutterText: { + color: theme.colors.foreground, fontFamily: Fonts.mono, fontSize: theme.fontSize.sm, lineHeight: theme.fontSize.sm * 1.45, @@ -178,9 +156,6 @@ function FilePreviewBody({ imagePreviewUri, }: FilePreviewBodyProps) { const { theme } = useUnistyles(); - const isDark = theme.colorScheme === "dark"; - const colorMap = isDark ? darkHighlightColors : lightHighlightColors; - const baseColor = isDark ? "#c9d1d9" : "#24292f"; const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []); const isMarkdownFile = preview?.kind === "text" && isRenderedMarkdownFile(filePath); @@ -258,14 +233,7 @@ function FilePreviewBody({ const codeLines = ( {keyedLines.map(({ key, tokens, lineNumber }) => ( - + ))} ); diff --git a/packages/app/src/components/highlighted-code-block.tsx b/packages/app/src/components/highlighted-code-block.tsx new file mode 100644 index 000000000..b3e1ce3b5 --- /dev/null +++ b/packages/app/src/components/highlighted-code-block.tsx @@ -0,0 +1,269 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Pressable, + Text, + View, + type StyleProp, + type TextStyle, + type ViewStyle, +} from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import * as Clipboard from "expo-clipboard"; +import { Check, Copy } from "lucide-react-native"; +import { highlightCode, type HighlightToken } from "@getpaseo/highlight"; +import { isNative, isWeb } from "@/constants/platform"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; + +interface HighlightedCodeBlockProps { + code: string; + language: string | null | undefined; + inheritedStyles: TextStyle; + textStyle: TextStyle; +} + +// Fence info strings ("```ts", "```typescript", "```ts {1,3}") map to the +// extension-based parser table in @getpaseo/highlight. Aliases here only +// cover names that don't already match an extension key in parsers.ts. +const LANGUAGE_ALIASES: Record = { + typescript: "ts", + javascript: "js", + python: "py", + rust: "rs", + golang: "go", + "c++": "cpp", + objc: "m", + "objective-c": "m", + markdown: "md", + elixir: "ex", +}; + +function fenceLanguageToExtension(info: string | null | undefined): string | null { + if (!info) return null; + const first = info.trim().split(/\s+/)[0]?.toLowerCase(); + if (!first) return null; + const normalized = first.replace(/^\./, ""); + return LANGUAGE_ALIASES[normalized] ?? normalized; +} + +// Cross-instance cache for tokenized code blocks. Tokenization is +// theme-independent (colors are applied at render time), so the key is just +// (language, code). Bounded by entry count — 200 is generous for a chat +// transcript, code blocks rarely repeat beyond a handful of distinct shapes. +class LRUCache { + private readonly map = new Map(); + constructor(private readonly max: number) {} + + get(key: K): V | undefined { + const value = this.map.get(key); + if (value === undefined) return undefined; + this.map.delete(key); + this.map.set(key, value); + return value; + } + + set(key: K, value: V): void { + if (this.map.has(key)) this.map.delete(key); + else if (this.map.size >= this.max) { + const oldest = this.map.keys().next().value; + if (oldest !== undefined) this.map.delete(oldest); + } + this.map.set(key, value); + } +} + +const tokenizationCache = new LRUCache(200); + +export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({ + code, + language, + inheritedStyles, + textStyle, +}: HighlightedCodeBlockProps) { + // Box styles (bg / padding / border / radius / margin) go on the wrapper View + // so the absolute copy button positions relative to the visible code area, + // not to a parent that includes the Text's own marginVertical. + const { containerStyle, innerTextStyle } = useMemo( + () => splitFenceStyle(inheritedStyles, textStyle), + [inheritedStyles, textStyle], + ); + + const keyedLines = useMemo(() => { + const ext = fenceLanguageToExtension(language); + if (!ext) return null; + const cacheKey = `${ext}:${code}`; + const cached = tokenizationCache.get(cacheKey); + if (cached) return cached; + let tokenizedLines: HighlightToken[][]; + try { + tokenizedLines = highlightCode(code, `x.${ext}`); + } catch { + return null; + } + const result = tokenizedLines.map(toKeyedLine); + tokenizationCache.set(cacheKey, result); + return result; + }, [code, language]); + + const isCompact = useIsCompactFormFactor(); + const [isHovered, setIsHovered] = useState(false); + const handlePointerEnter = useCallback(() => setIsHovered(true), []); + const handlePointerLeave = useCallback(() => setIsHovered(false), []); + const controlsVisible = isHovered || isNative || isCompact; + const getCode = useCallback(() => code, [code]); + + return ( + + {keyedLines ? ( + + {keyedLines.map((line, lineIndex) => ( + + {lineIndex > 0 ? "\n" : null} + {line.tokens.map(({ key, token }) => ( + + ))} + + ))} + + ) : ( + {code} + )} + + + ); +}); + +interface KeyedToken { + key: string; + token: HighlightToken; +} + +interface KeyedLine { + key: string; + tokens: KeyedToken[]; +} + +function toKeyedLine(tokens: HighlightToken[], lineIndex: number): KeyedLine { + return { + key: `line-${lineIndex}`, + tokens: tokens.map((token, tokenIndex) => ({ + key: `${lineIndex}-${tokenIndex}`, + token, + })), + }; +} + +interface TokenSpanProps { + token: HighlightToken; +} + +const TokenSpan = React.memo(function TokenSpan({ token }: TokenSpanProps) { + if (!token.style) return token.text; + return {token.text}; +}); + +interface SplitStyles { + containerStyle: StyleProp; + innerTextStyle: StyleProp; +} + +const CONTAINER_BASE: ViewStyle = { position: "relative" }; +const WEB_SELECTABLE: TextStyle = isWeb ? ({ userSelect: "text" } as TextStyle) : {}; + +function splitFenceStyle(inheritedStyles: TextStyle, textStyle: TextStyle): SplitStyles { + const { fontFamily, fontSize, color, ...box } = textStyle; + const textOnly: TextStyle = { ...WEB_SELECTABLE }; + if (fontFamily !== undefined) textOnly.fontFamily = fontFamily; + if (fontSize !== undefined) textOnly.fontSize = fontSize; + if (color !== undefined) textOnly.color = color; + return { + containerStyle: [box as ViewStyle, CONTAINER_BASE], + innerTextStyle: [inheritedStyles, textOnly], + }; +} + +interface CopyButtonProps { + getCode: () => string; + visible: boolean; +} + +const COPIED_RESET_MS = 1500; + +const CopyButton = React.memo(function CopyButton({ getCode, visible }: CopyButtonProps) { + const [copied, setCopied] = useState(false); + const resetRef = useRef | null>(null); + + useEffect( + () => () => { + if (resetRef.current) clearTimeout(resetRef.current); + }, + [], + ); + + const handlePress = useCallback(async () => { + const content = getCode(); + if (!content) return; + await Clipboard.setStringAsync(content); + setCopied(true); + if (resetRef.current) clearTimeout(resetRef.current); + resetRef.current = setTimeout(() => { + setCopied(false); + resetRef.current = null; + }, COPIED_RESET_MS); + }, [getCode]); + + const visibilityStyle = visible + ? copyButtonStyles.containerVisible + : copyButtonStyles.containerHidden; + const wrapperStyle = useMemo( + () => [copyButtonStyles.container, visibilityStyle], + [visibilityStyle], + ); + + return ( + + {({ hovered }) => { + const iconColor = hovered + ? copyButtonStyles.iconHoveredColor.color + : copyButtonStyles.iconColor.color; + return copied ? ( + + ) : ( + + ); + }} + + ); +}); + +const copyButtonStyles = StyleSheet.create((theme) => ({ + container: { + position: "absolute", + top: theme.spacing[2], + right: theme.spacing[2], + padding: theme.spacing[1], + }, + containerVisible: { + opacity: 1, + }, + containerHidden: { + opacity: 0, + }, + iconColor: { + color: theme.colors.foregroundMuted, + }, + iconHoveredColor: { + color: theme.colors.foreground, + }, +})); diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index b4feabf0d..0b5682ecb 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -74,6 +74,7 @@ import { getMarkdownListMarker, getMarkdownNextSiblingType } from "@/utils/markd import { type AssistantFileLinkSource } from "@/utils/assistant-file-link-resolver"; import { useAssistantFileLinkResolver } from "@/hooks/use-assistant-file-link-resolver"; import type { ToastApi } from "@/components/toast-host"; +import { HighlightedCodeBlock } from "@/components/highlighted-code-block"; import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; import { formatDuration, formatMessageTimestamp } from "@/utils/time"; import { @@ -1650,13 +1651,13 @@ export const AssistantMessage = memo(function AssistantMessage({ styles: MarkdownStyles, inheritedStyles: TextStyle = {}, ) => ( - - {node.content} - + /> ), fence: ( node: ASTNode, @@ -1665,13 +1666,13 @@ export const AssistantMessage = memo(function AssistantMessage({ styles: MarkdownStyles, inheritedStyles: TextStyle = {}, ) => ( - - {node.content} - + /> ), code_inline: ( node: ASTNode, diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 683354a01..04d1c9470 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -54,11 +54,7 @@ import { useCheckoutStatusQuery } from "@/git/use-status-query"; import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; import { useChangesPreferences } from "@/hooks/use-changes-preferences"; import { DiffScroll } from "@/components/diff-scroll"; -import { - darkHighlightColors, - lightHighlightColors, - type HighlightStyle as HighlightStyleKey, -} from "@getpaseo/highlight"; +import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { Fonts } from "@/constants/theme"; import { shouldAnchorHeaderBeforeCollapse } from "@/git/diff-scroll"; @@ -136,8 +132,6 @@ function expandAllButtonStyle({ return [styles.expandAllButton, (Boolean(hovered) || pressed) && styles.diffStatusRowHovered]; } -type HighlightStyle = NonNullable; - interface HighlightedTextProps { tokens: HighlightToken[]; wrapLines?: boolean; @@ -157,31 +151,14 @@ function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefine : { whiteSpace: "pre", overflowWrap: "normal" }; } -function HighlightedToken({ - text, - color, - lineHeight, -}: { - text: string; - color: string; - lineHeight: number; -}) { - const tokenStyle = useMemo(() => ({ color, lineHeight }), [color, lineHeight]); - return {text}; +function HighlightedToken({ token }: { token: HighlightToken }) { + return {token.text}; } function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) { const { theme } = useUnistyles(); - const isDark = theme.colorScheme === "dark"; const lineHeight = theme.lineHeight.diff; - const getTokenColor = (style: HighlightStyle | null): string => { - const baseColor = isDark ? "#c9d1d9" : "#24292f"; - if (!style) return baseColor; - const colors = isDark ? darkHighlightColors : lightHighlightColors; - return colors[style as HighlightStyleKey] ?? baseColor; - }; - const containerStyle = useMemo( () => [styles.diffLineText, { lineHeight, ...getWrappedTextStyle(wrapLines) }], [lineHeight, wrapLines], @@ -195,12 +172,7 @@ function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) { return ( {keyedTokens.map(({ key, token }) => ( - + ))} ); diff --git a/packages/app/src/styles/syntax-token-styles.ts b/packages/app/src/styles/syntax-token-styles.ts new file mode 100644 index 000000000..53fde3f95 --- /dev/null +++ b/packages/app/src/styles/syntax-token-styles.ts @@ -0,0 +1,40 @@ +import { StyleSheet } from "react-native-unistyles"; + +// Per-token color styles for syntax-highlighted code. Each value reads a +// theme path so the Unistyles Babel plugin tracks the dependency and updates +// native style values on theme changes — no React re-render of consumers. +// Used by message code blocks (hot path), file preview, and the diff viewer. +export const syntaxTokenStyles = StyleSheet.create((theme) => ({ + base: { color: theme.colors.foreground }, + keyword: { color: theme.colors.syntax.keyword }, + comment: { color: theme.colors.syntax.comment }, + string: { color: theme.colors.syntax.string }, + number: { color: theme.colors.syntax.number }, + literal: { color: theme.colors.syntax.literal }, + function: { color: theme.colors.syntax.function }, + definition: { color: theme.colors.syntax.definition }, + class: { color: theme.colors.syntax.class }, + type: { color: theme.colors.syntax.type }, + tag: { color: theme.colors.syntax.tag }, + attribute: { color: theme.colors.syntax.attribute }, + property: { color: theme.colors.syntax.property }, + variable: { color: theme.colors.syntax.variable }, + operator: { color: theme.colors.syntax.operator }, + punctuation: { color: theme.colors.syntax.punctuation }, + regexp: { color: theme.colors.syntax.regexp }, + escape: { color: theme.colors.syntax.escape }, + meta: { color: theme.colors.syntax.meta }, + heading: { color: theme.colors.syntax.heading }, + link: { color: theme.colors.syntax.link }, +})); + +type SyntaxTokenStyleValue = (typeof syntaxTokenStyles)["base"]; + +// Accepts a plain string so diff tokens (server-typed `string | null`) and +// @getpaseo/highlight tokens (typed `HighlightStyle | null`) share one path. +// Unknown styles fall back to the base color. +export function syntaxTokenStyleFor(style: string | null | undefined): SyntaxTokenStyleValue { + if (!style) return syntaxTokenStyles.base; + const indexed = syntaxTokenStyles as unknown as Record; + return indexed[style] ?? syntaxTokenStyles.base; +} diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index 43617e483..6017f5c2f 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -1,3 +1,5 @@ +import { darkHighlightColors, lightHighlightColors } from "@getpaseo/highlight"; + export const baseColors = { // Base colors white: "#ffffff", @@ -518,6 +520,7 @@ function buildDarkTheme(semanticColors: ReturnType