feat(app): syntax highlighting and copy button for chat code blocks (#1062)

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.
This commit is contained in:
Mohamed Boudra
2026-05-17 16:01:08 +08:00
committed by GitHub
parent 952b58c0eb
commit f6afe0d864
7 changed files with 347 additions and 82 deletions
+10 -42
View File
@@ -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<HighlightStyle, string>;
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 (
<View style={codeLineStyles.line}>
<View style={gutterStyle}>
<Text numberOfLines={1} style={gutterTextStyle}>
<Text numberOfLines={1} style={codeLineStyles.gutterText}>
{String(lineNumber)}
</Text>
</View>
<Text selectable style={codeLineStyles.lineText}>
{keyedTokens.map(({ key, token }) => (
<CodeLineToken
key={key}
color={token.style ? (colorMap[token.style] ?? baseColor) : baseColor}
text={token.text}
/>
<CodeLineToken key={key} token={token} />
))}
</Text>
</View>
@@ -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 style={style}>{text}</Text>;
function CodeLineToken({ token }: CodeLineTokenProps) {
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</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 = (
<View>
{keyedLines.map(({ key, tokens, lineNumber }) => (
<CodeLine
key={key}
tokens={tokens}
lineNumber={lineNumber}
gutterWidth={gutterWidth}
colorMap={colorMap}
baseColor={baseColor}
/>
<CodeLine key={key} tokens={tokens} lineNumber={lineNumber} gutterWidth={gutterWidth} />
))}
</View>
);
@@ -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<string, string> = {
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<K, V> {
private readonly map = new Map<K, V>();
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<string, KeyedLine[]>(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<KeyedLine[] | null>(() => {
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 (
<View
style={containerStyle}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
{keyedLines ? (
<Text style={innerTextStyle}>
{keyedLines.map((line, lineIndex) => (
<React.Fragment key={line.key}>
{lineIndex > 0 ? "\n" : null}
{line.tokens.map(({ key, token }) => (
<TokenSpan key={key} token={token} />
))}
</React.Fragment>
))}
</Text>
) : (
<Text style={innerTextStyle}>{code}</Text>
)}
<CopyButton getCode={getCode} visible={controlsVisible} />
</View>
);
});
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 <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
});
interface SplitStyles {
containerStyle: StyleProp<ViewStyle>;
innerTextStyle: StyleProp<TextStyle>;
}
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<ReturnType<typeof setTimeout> | 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 (
<Pressable
onPress={handlePress}
style={wrapperStyle}
pointerEvents={visible ? "auto" : "none"}
accessibilityRole="button"
accessibilityLabel={copied ? "Copied" : "Copy code"}
hitSlop={8}
>
{({ hovered }) => {
const iconColor = hovered
? copyButtonStyles.iconHoveredColor.color
: copyButtonStyles.iconColor.color;
return copied ? (
<Check size={14} color={iconColor} />
) : (
<Copy size={14} color={iconColor} />
);
}}
</Pressable>
);
});
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,
},
}));
+9 -8
View File
@@ -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 = {},
) => (
<MarkdownInheritedText
<HighlightedCodeBlock
key={node.key}
code={node.content}
language={null}
inheritedStyles={inheritedStyles}
textStyle={styles.code_block}
>
{node.content}
</MarkdownInheritedText>
/>
),
fence: (
node: ASTNode,
@@ -1665,13 +1666,13 @@ export const AssistantMessage = memo(function AssistantMessage({
styles: MarkdownStyles,
inheritedStyles: TextStyle = {},
) => (
<MarkdownInheritedText
<HighlightedCodeBlock
key={node.key}
code={node.content}
language={node.sourceInfo}
inheritedStyles={inheritedStyles}
textStyle={styles.fence}
>
{node.content}
</MarkdownInheritedText>
/>
),
code_inline: (
node: ASTNode,
+4 -32
View File
@@ -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<HighlightToken["style"]>;
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 style={tokenStyle}>{text}</Text>;
function HighlightedToken({ token }: { token: HighlightToken }) {
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</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 (
<Text style={containerStyle}>
{keyedTokens.map(({ key, token }) => (
<HighlightedToken
key={key}
text={token.text}
color={getTokenColor(token.style)}
lineHeight={lineHeight}
/>
<HighlightedToken key={key} token={token} />
))}
</Text>
);
@@ -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<string, SyntaxTokenStyleValue>;
return indexed[style] ?? syntaxTokenStyles.base;
}
+4
View File
@@ -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<typeof buildDarkSemanticColor
colors: {
...semanticColors,
palette: baseColors,
syntax: darkHighlightColors,
},
shadow: darkShadow,
...commonTheme,
@@ -535,6 +538,7 @@ export const lightTheme = {
colors: {
...lightSemanticColors,
palette: baseColors,
syntax: lightHighlightColors,
},
shadow: {
sm: {
@@ -0,0 +1,11 @@
// The runtime sets `sourceInfo` on fence/code nodes (tokensToAST.js sets
// `sourceInfo: token.info`), but the shipped .d.ts omits the field. The
// re-export is what marks this file as a module so the `declare module`
// below augments the package instead of shadowing it.
export type { ASTNode } from "react-native-markdown-display";
declare module "react-native-markdown-display" {
interface ASTNode {
sourceInfo?: string;
}
}