Syntax-highlight Edit, Write, and Read tool calls (#1218)
Highlighting reuses the existing code-block tokenizer and LRU cache, runs lazily on expand, and falls back to plain text above a size cap so a large Read can't stall the main thread. The diff highlighter reconstructs the old and new file text by position rather than from @@ line ranges, so diffs that arrive without real hunk headers still highlight. Read content is normalized server-side: the cat -n line-number gutter some providers emit is stripped so content is uniformly raw source, with the first line number surfaced as offset for the client to rebuild the gutter.
This commit is contained in:
@@ -4,6 +4,8 @@ import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import type { DiffLine } from "@/utils/tool-call-parsers";
|
||||
import { diffLinePrefix } from "@/utils/diff-highlight";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { getCodeInsets } from "./code-insets";
|
||||
@@ -40,6 +42,26 @@ function DiffLineRow({ line }: { line: DiffLine }) {
|
||||
[line.type],
|
||||
);
|
||||
|
||||
const prefixStyle = React.useMemo(
|
||||
() => [
|
||||
line.type === "add" && styles.addText,
|
||||
line.type === "remove" && styles.removeText,
|
||||
line.type === "context" && styles.contextText,
|
||||
],
|
||||
[line.type],
|
||||
);
|
||||
|
||||
if (line.tokens) {
|
||||
return (
|
||||
<View style={lineContainerStyle}>
|
||||
<Text style={styles.lineText}>
|
||||
<Text style={prefixStyle}>{diffLinePrefix(line)}</Text>
|
||||
<DiffTokens tokens={line.tokens} />
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={lineContainerStyle}>
|
||||
{line.segments ? (
|
||||
@@ -62,6 +84,22 @@ function DiffLineRow({ line }: { line: DiffLine }) {
|
||||
);
|
||||
}
|
||||
|
||||
function DiffTokens({ tokens }: { tokens: NonNullable<DiffLine["tokens"]> }) {
|
||||
const keyed = React.useMemo(
|
||||
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
|
||||
[tokens],
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{keyed.map(({ key, token }) => (
|
||||
<Text key={key} style={token.style ? syntaxTokenStyleFor(token.style) : undefined}>
|
||||
{token.text}
|
||||
</Text>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffSegment({
|
||||
segment,
|
||||
lineType,
|
||||
|
||||
@@ -4,10 +4,11 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { Check, Copy } from "lucide-react-native";
|
||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
import type { HighlightToken } from "@getpaseo/highlight";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import { highlightToKeyedLines, type KeyedLine } from "@/utils/highlight-cache";
|
||||
|
||||
interface HighlightedCodeBlockProps {
|
||||
code: string;
|
||||
@@ -44,34 +45,6 @@ function stripTerminalFenceNewline(code: string): string {
|
||||
return code.endsWith("\n") ? code.slice(0, -1) : code;
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -87,22 +60,10 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
|
||||
);
|
||||
const renderedCode = useMemo(() => stripTerminalFenceNewline(code), [code]);
|
||||
|
||||
const keyedLines = useMemo<KeyedLine[] | null>(() => {
|
||||
const ext = fenceLanguageToExtension(language);
|
||||
if (!ext) return null;
|
||||
const cacheKey = `${ext}:${renderedCode}`;
|
||||
const cached = tokenizationCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
let tokenizedLines: HighlightToken[][];
|
||||
try {
|
||||
tokenizedLines = highlightCode(renderedCode, `x.${ext}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const result = tokenizedLines.map(toKeyedLine);
|
||||
tokenizationCache.set(cacheKey, result);
|
||||
return result;
|
||||
}, [renderedCode, language]);
|
||||
const keyedLines = useMemo<KeyedLine[] | null>(
|
||||
() => highlightToKeyedLines(renderedCode, fenceLanguageToExtension(language)),
|
||||
[renderedCode, language],
|
||||
);
|
||||
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
@@ -127,26 +88,6 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function renderCodeSegments(keyedLines: KeyedLine[]): React.ReactNode[] {
|
||||
const segments: React.ReactNode[] = [];
|
||||
for (let lineIndex = 0; lineIndex < keyedLines.length; lineIndex += 1) {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import React from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import type { KeyedLine, KeyedToken } from "@/utils/highlight-cache";
|
||||
|
||||
// Line height kept in sync with the plain `scrollText` style in
|
||||
// tool-call-details so a highlighted block lines up with an unhighlighted
|
||||
// fallback and blank lines keep their height.
|
||||
const CODE_LINE_HEIGHT = 18;
|
||||
const ZERO_WIDTH = "";
|
||||
|
||||
interface HighlightedLinesProps {
|
||||
lines: KeyedLine[];
|
||||
// 1-based line number of the first line; when set, a line-number gutter is
|
||||
// rendered (used by Read, which carries a server-normalized offset).
|
||||
startLine?: number;
|
||||
}
|
||||
|
||||
function ContentLine({ line }: { line: KeyedLine }) {
|
||||
return (
|
||||
<Text selectable style={styles.lineText}>
|
||||
{line.tokens.length === 0
|
||||
? ZERO_WIDTH
|
||||
: line.tokens.map(({ key, token }: KeyedToken) => (
|
||||
<Text key={key} style={token.style ? syntaxTokenStyleFor(token.style) : undefined}>
|
||||
{token.text}
|
||||
</Text>
|
||||
))}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const GutteredLine = React.memo(function GutteredLine({
|
||||
line,
|
||||
lineNumber,
|
||||
digits,
|
||||
}: {
|
||||
line: KeyedLine;
|
||||
lineNumber: number;
|
||||
digits: number;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.gutterText}>{String(lineNumber).padStart(digits)} </Text>
|
||||
<ContentLine line={line} />
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
// Renders pre-tokenized lines (from the shared highlight cache), optionally with
|
||||
// a line-number gutter. Callers decide whether to highlight at all, so the
|
||||
// expensive size-cap / unsupported-language fallback stays a single plain Text.
|
||||
export function HighlightedLines({ lines, startLine }: HighlightedLinesProps) {
|
||||
if (startLine === undefined) {
|
||||
return (
|
||||
<View>
|
||||
{lines.map((line) => (
|
||||
<ContentLine key={line.key} line={line} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const lastLineNumber = startLine + lines.length - 1;
|
||||
const digits = Math.max(2, String(lastLineNumber).length);
|
||||
return (
|
||||
<View>
|
||||
{lines.map((line, index) => (
|
||||
<GutteredLine key={line.key} line={line} lineNumber={startLine + index} digits={digits} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
minHeight: CODE_LINE_HEIGHT,
|
||||
},
|
||||
gutterText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: CODE_LINE_HEIGHT,
|
||||
color: theme.colors.foregroundMuted,
|
||||
opacity: 0.6,
|
||||
userSelect: "none",
|
||||
flexShrink: 0,
|
||||
},
|
||||
lineText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: CODE_LINE_HEIGHT,
|
||||
...(isWeb
|
||||
? {
|
||||
whiteSpace: "pre",
|
||||
overflowWrap: "normal",
|
||||
}
|
||||
: null),
|
||||
},
|
||||
}));
|
||||
@@ -11,9 +11,12 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
|
||||
import { highlightDiffLines } from "@/utils/diff-highlight";
|
||||
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { extensionFromPath, highlightToKeyedLines } from "@/utils/highlight-cache";
|
||||
import { HighlightedLines } from "./highlighted-content";
|
||||
import { DiffViewer } from "./diff-viewer";
|
||||
import { getCodeInsets } from "./code-insets";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -143,10 +146,10 @@ function useDetailStyles(
|
||||
function useDiffLines(detail: ToolCallDetail | undefined): DiffLine[] | undefined {
|
||||
return useMemo(() => {
|
||||
if (!detail || detail.type !== "edit") return undefined;
|
||||
if (detail.unifiedDiff) {
|
||||
return parseUnifiedDiff(detail.unifiedDiff);
|
||||
}
|
||||
return buildLineDiff(detail.oldString ?? "", detail.newString ?? "");
|
||||
const diffLines = detail.unifiedDiff
|
||||
? parseUnifiedDiff(detail.unifiedDiff)
|
||||
: buildLineDiff(detail.oldString ?? "", detail.newString ?? "");
|
||||
return highlightDiffLines(diffLines, detail.filePath);
|
||||
}, [detail]);
|
||||
}
|
||||
|
||||
@@ -431,9 +434,22 @@ interface ScrollableContentProps {
|
||||
content: string;
|
||||
ds: DetailStyles;
|
||||
wrapInSectionFill?: boolean;
|
||||
// Drives syntax highlighting (extension only) and, with startLine, a gutter.
|
||||
filePath?: string | null;
|
||||
startLine?: number;
|
||||
}
|
||||
|
||||
function ScrollableTextSection({ content, ds, wrapInSectionFill = true }: ScrollableContentProps) {
|
||||
function ScrollableTextSection({
|
||||
content,
|
||||
ds,
|
||||
wrapInSectionFill = true,
|
||||
filePath,
|
||||
startLine,
|
||||
}: ScrollableContentProps) {
|
||||
const keyedLines = useMemo(
|
||||
() => (filePath ? highlightToKeyedLines(content, extensionFromPath(filePath)) : null),
|
||||
[content, filePath],
|
||||
);
|
||||
const body = (
|
||||
<ScrollView
|
||||
style={ds.scrollAreaFillStyle}
|
||||
@@ -447,9 +463,13 @@ function ScrollableTextSection({ content, ds, wrapInSectionFill = true }: Scroll
|
||||
showsHorizontalScrollIndicator={true}
|
||||
style={ds.webScrollbarStyle}
|
||||
>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{content}
|
||||
</Text>
|
||||
{keyedLines ? (
|
||||
<HighlightedLines lines={keyedLines} startLine={startLine} />
|
||||
) : (
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{content}
|
||||
</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
);
|
||||
@@ -669,14 +689,27 @@ function buildDetailSections(
|
||||
return [
|
||||
<View key="write" style={ds.sectionFillStyle}>
|
||||
{detail.content ? (
|
||||
<ScrollableTextSection content={detail.content} ds={ds} wrapInSectionFill={false} />
|
||||
<ScrollableTextSection
|
||||
content={detail.content}
|
||||
ds={ds}
|
||||
wrapInSectionFill={false}
|
||||
filePath={detail.filePath}
|
||||
/>
|
||||
) : null}
|
||||
</View>,
|
||||
];
|
||||
}
|
||||
if (detail.type === "read") {
|
||||
if (!detail.content) return [];
|
||||
return [<ScrollableTextSection key="read" content={detail.content} ds={ds} />];
|
||||
return [
|
||||
<ScrollableTextSection
|
||||
key="read"
|
||||
content={detail.content}
|
||||
ds={ds}
|
||||
filePath={detail.filePath}
|
||||
startLine={detail.offset ?? 1}
|
||||
/>,
|
||||
];
|
||||
}
|
||||
if (detail.type === "search") {
|
||||
return buildSearchSections(detail, ds);
|
||||
|
||||
Reference in New Issue
Block a user