Merge branch 'main' of github.com:getpaseo/paseo

This commit is contained in:
Mohamed Boudra
2026-05-29 14:16:19 +07:00
39 changed files with 1993 additions and 146 deletions
@@ -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);