Merge branch 'main' of github.com:getpaseo/paseo
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