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);
@@ -170,6 +170,46 @@ describe("checkout-git-actions-store", () => {
).toBe("idle");
});
it("refreshes git and GitHub state and reports success", async () => {
const client = {
checkoutRefresh: vi.fn(async () => ({ success: true, error: null })),
};
useSessionStore.setState((state) => ({
...state,
sessions: {
...state.sessions,
[serverId]: { client } as unknown as (typeof state.sessions)[string],
},
}));
await useCheckoutGitActionsStore.getState().refresh({ serverId, cwd });
expect(client.checkoutRefresh).toHaveBeenCalledWith(cwd);
expect(
useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "refresh" }),
).toBe("success");
});
it("surfaces a refresh error and returns to idle", async () => {
const client = {
checkoutRefresh: vi.fn(async () => ({ error: { message: "not a git repository" } })),
};
useSessionStore.setState((state) => ({
...state,
sessions: {
...state.sessions,
[serverId]: { client } as unknown as (typeof state.sessions)[string],
},
}));
await expect(useCheckoutGitActionsStore.getState().refresh({ serverId, cwd })).rejects.toThrow(
"not a git repository",
);
expect(
useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "refresh" }),
).toBe("idle");
});
it("enables PR auto-merge when the daemon advertises auto-merge actions", async () => {
const client = {
checkoutGithubSetAutoMerge: vi.fn(async () => ({
+17
View File
@@ -28,6 +28,7 @@ export type CheckoutGitAsyncActionId =
| "pull"
| "push"
| "pull-and-push"
| "refresh"
| "create-pr"
| "merge-pr-squash"
| "merge-pr-merge"
@@ -236,6 +237,7 @@ interface CheckoutGitActionsStoreState {
pull: (params: { serverId: string; cwd: string }) => Promise<void>;
push: (params: { serverId: string; cwd: string }) => Promise<void>;
pullAndPush: (params: { serverId: string; cwd: string }) => Promise<void>;
refresh: (params: { serverId: string; cwd: string }) => Promise<void>;
createPr: (params: { serverId: string; cwd: string }) => Promise<void>;
mergePr: (params: {
serverId: string;
@@ -360,6 +362,21 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
});
},
refresh: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "refresh",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutRefresh(cwd);
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
pullAndPush: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,
+81 -1
View File
@@ -25,7 +25,8 @@ import {
type ViewStyle,
type TextStyle,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
AlignJustify,
@@ -41,6 +42,7 @@ import {
ListChevronsUpDown,
Pilcrow,
RefreshCcw,
RotateCw,
Upload,
WrapText,
} from "lucide-react-native";
@@ -78,6 +80,10 @@ import { lineNumberGutterWidth } from "@/components/code-insets";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { GitActionsSplitButton } from "@/git/actions-split-button";
import { useGitActions } from "@/git/use-actions";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { useToast } from "@/contexts/toast-context";
import { useSessionStore } from "@/stores/session-store";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { usePanelStore } from "@/stores/panel-store";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
@@ -1222,6 +1228,44 @@ function DiffFilesToolbar({
);
}
interface DiffRefreshButtonProps {
isRefreshing: boolean;
toggleStyle: PressableStyleFn;
onPress: () => void;
}
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const refreshIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function DiffRefreshButton({ isRefreshing, toggleStyle, onPress }: DiffRefreshButtonProps) {
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={isRefreshing ? "Refreshing" : "Refresh git and GitHub state"}
testID="changes-refresh"
style={toggleStyle}
onPress={onPress}
disabled={isRefreshing}
>
<View style={styles.refreshIcon}>
{isRefreshing ? (
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={refreshIconColorMapping} />
) : (
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={refreshIconColorMapping} />
)}
</View>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>Refresh</Text>
</TooltipContent>
</Tooltip>
);
}
type DiffFlatItem =
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean }
| { type: "body"; file: ParsedDiffFile; fileIndex: number };
@@ -1559,6 +1603,29 @@ export function GitDiffPane({
[controlSurfaceColor],
);
const refreshToggleStyle = useMemo(
() => buildExpandAllButtonStyle(controlSurfaceColor),
[controlSurfaceColor],
);
const toast = useToast();
const refreshSupported = useSessionStore(
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true,
);
const runRefresh = useCheckoutGitActionsStore((s) => s.refresh);
const isRefreshing =
useCheckoutGitActionsStore((s) => s.getStatus({ serverId, cwd, actionId: "refresh" })) ===
"pending";
const handleRefresh = useCallback(() => {
if (isRefreshing) {
return;
}
void runRefresh({ serverId, cwd }).catch((error) => {
toast.error(error instanceof Error ? error.message : "Failed to refresh git state.");
});
}, [cwd, isRefreshing, runRefresh, serverId, toast]);
const {
status,
isLoading: isStatusLoading,
@@ -2088,6 +2155,13 @@ export function GitDiffPane({
onToggleExpandAll={handleToggleExpandAll}
/>
) : null}
{refreshSupported ? (
<DiffRefreshButton
isRefreshing={isRefreshing}
toggleStyle={refreshToggleStyle}
onPress={handleRefresh}
/>
) : null}
</View>
</View>
</View>
@@ -2216,6 +2290,12 @@ const styles = StyleSheet.create((theme) => ({
toggleButtonSelected: {
backgroundColor: theme.colors.surface2,
},
refreshIcon: {
width: ICON_SIZE.md,
height: ICON_SIZE.md,
alignItems: "center",
justifyContent: "center",
},
expandAllButton: {
flexDirection: "row",
alignItems: "center",
+112 -11
View File
@@ -45,7 +45,14 @@ import {
type Settings as EffectiveSettings,
} from "@/hooks/use-settings";
import { THEME_SWATCHES } from "@/styles/theme";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
useHostRuntimeIsConnected,
useHosts,
} from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import type { HostProfile } from "@/types/host-connection";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { confirmDialog } from "@/utils/confirm-dialog";
@@ -442,29 +449,114 @@ function DiagnosticsSection({
}
interface AboutSectionProps {
appVersion: string | null;
appVersionText: string;
isDesktopApp: boolean;
}
function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) {
function AboutSection({ appVersion, appVersionText, isDesktopApp }: AboutSectionProps) {
return (
<SettingsSection title="About">
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Version</Text>
<>
<SettingsSection title="About">
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>App version</Text>
<Text style={settingsStyles.rowHint}>This device</Text>
</View>
<Text style={styles.aboutValue}>{appVersionText}</Text>
</View>
<Text style={styles.aboutValue}>{appVersionText}</Text>
{isDesktopApp ? <DesktopAppUpdateRow /> : null}
</View>
{isDesktopApp ? <DesktopAppUpdateRow /> : null}
</View>
</SettingsSection>
<ConnectedHostsSection clientVersion={appVersion} />
<View style={styles.aboutCommunity}>
<CommunityLinks />
</View>
</>
);
}
function normalizeVersion(version: string | null | undefined): string | null {
const trimmed = version?.trim();
if (!trimmed) return null;
return trimmed.replace(/^v/i, "");
}
function ConnectedHostsSection({ clientVersion }: { clientVersion: string | null }) {
const hosts = useHosts();
if (hosts.length === 0) {
return null;
}
return (
<SettingsSection title="Connected hosts">
<View style={settingsStyles.card}>
{hosts.map((host, index) => (
<HostVersionRow
key={host.serverId}
host={host}
showBorder={index > 0}
clientVersion={clientVersion}
/>
))}
</View>
</SettingsSection>
);
}
function HostVersionRow({
host,
showBorder,
clientVersion,
}: {
host: HostProfile;
showBorder: boolean;
clientVersion: string | null;
}) {
const isConnected = useHostRuntimeIsConnected(host.serverId);
const daemonVersion = useSessionStore(
(state) => state.sessions[host.serverId]?.serverInfo?.version ?? null,
);
const rowStyle = useMemo(
() => [settingsStyles.row, showBorder && settingsStyles.rowBorder],
[showBorder],
);
const normalizedHost = normalizeVersion(daemonVersion);
const normalizedClient = normalizeVersion(clientVersion);
const isMismatch =
normalizedHost !== null && normalizedClient !== null && normalizedHost !== normalizedClient;
let valueText: string;
if (!isConnected) {
valueText = "Offline";
} else if (normalizedHost) {
valueText = formatVersionWithPrefix(normalizedHost);
} else {
valueText = "—";
}
const valueStyle = useMemo(
() => [styles.aboutValue, isMismatch && styles.aboutVersionMismatch],
[isMismatch],
);
return (
<View style={rowStyle}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{host.label}
</Text>
{isMismatch ? (
<Text style={settingsStyles.rowHint}>Version differs from this device</Text>
) : null}
</View>
<Text style={valueStyle}>{valueText}</Text>
</View>
);
}
function getUpdateButtonLabel(
isInstalling: boolean,
latestVersion: string | null | undefined,
@@ -1106,7 +1198,13 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
/>
);
case "about":
return <AboutSection appVersionText={appVersionText} isDesktopApp={isDesktopApp} />;
return (
<AboutSection
appVersion={appVersion}
appVersionText={appVersionText}
isDesktopApp={isDesktopApp}
/>
);
}
}
return null;
@@ -1266,6 +1364,9 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
aboutVersionMismatch: {
color: theme.colors.palette.amber[500],
},
aboutErrorText: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
@@ -203,6 +203,7 @@ function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDa
return {
mcp: { injectIntoAgents: false },
providers,
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
appendSystemPrompt: "",
};
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { highlightDiffLines } from "./diff-highlight";
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "./tool-call-parsers";
function joinTokens(line: DiffLine): string {
return (line.tokens ?? []).map((token) => token.text).join("");
}
describe("highlightDiffLines", () => {
it("attaches tokens to add/remove/context lines for a supported language", () => {
const diff = buildLineDiff("const a = 1;\nconst b = 2;", "const a = 1;\nconst b = 3;");
const result = highlightDiffLines(diff, "/repo/src/index.ts");
const context = result.find((line) => line.type === "context");
const remove = result.find((line) => line.type === "remove");
const add = result.find((line) => line.type === "add");
expect(context?.tokens).toBeDefined();
expect(remove?.tokens).toBeDefined();
expect(add?.tokens).toBeDefined();
// Tokens reconstruct the code with the diff marker excluded.
expect(joinTokens(context as DiffLine)).toBe("const a = 1;");
expect(joinTokens(remove as DiffLine)).toBe("const b = 2;");
expect(joinTokens(add as DiffLine)).toBe("const b = 3;");
// A keyword token is recognised (proves real highlighting, not pass-through).
expect(add?.tokens?.some((token) => token.style === "keyword")).toBe(true);
});
it("highlights a Codex-style unified diff with bare @@ headers (no line ranges)", () => {
const unified = ["@@", "-const x = 1;", "+const x = 2;", " const y = 3;"].join("\n");
const result = highlightDiffLines(parseUnifiedDiff(unified), "/repo/a.ts");
const remove = result.find((line) => line.type === "remove");
const add = result.find((line) => line.type === "add");
const context = result.find((line) => line.type === "context");
expect(joinTokens(remove as DiffLine)).toBe("const x = 1;");
expect(joinTokens(add as DiffLine)).toBe("const x = 2;");
expect(joinTokens(context as DiffLine)).toBe("const y = 3;");
expect(result.find((line) => line.type === "header")?.tokens).toBeUndefined();
});
it("returns lines unchanged for an unsupported language", () => {
const diff = buildLineDiff("a", "b");
const result = highlightDiffLines(diff, "/repo/notes.unknownext");
expect(result).toBe(diff);
expect(result.every((line) => line.tokens === undefined)).toBe(true);
});
it("returns lines unchanged when there is no file path", () => {
const diff = buildLineDiff("a", "b");
expect(highlightDiffLines(diff, undefined)).toBe(diff);
});
});
+91
View File
@@ -0,0 +1,91 @@
import { isLanguageSupported, type HighlightToken } from "@getpaseo/highlight";
import { extensionFromPath, tokenizeToLines } from "@/utils/highlight-cache";
import type { DiffLine } from "@/utils/tool-call-parsers";
// The leading diff marker glyph for a line. The code on the line is the content
// with this prefix removed; we render the glyph separately so token colors
// apply only to the code.
export function diffLinePrefix(line: DiffLine): string {
switch (line.type) {
case "add":
return "+";
case "remove":
return "-";
case "context":
return " ";
default:
return "";
}
}
function diffLineCode(line: DiffLine): string {
const { content, type } = line;
if (type === "add" || type === "remove") {
return content.startsWith(type === "add" ? "+" : "-") ? content.slice(1) : content;
}
if (type === "context") {
return content.startsWith(" ") ? content.slice(1) : content;
}
return content;
}
// Attach syntax-highlight tokens to each diff line. Language comes from the file
// path (extension only). We reconstruct the old and new document text from the
// diff lines by position — counting context/remove into "old" and context/add
// into "new" — so this works regardless of whether the source diff carried real
// `@@ -n,m +n,m @@` line ranges (Codex emits bare `@@`). Each document is
// highlighted as a whole so the parser has cross-line context (multi-line
// strings, template literals, comments). Returns the input unchanged when the
// language is unsupported or the content exceeds the highlighter size cap.
export function highlightDiffLines(
diffLines: DiffLine[],
filePath: string | null | undefined,
): DiffLine[] {
const ext = extensionFromPath(filePath);
// Gate on real grammar support: an unsupported language would tokenize to a
// single style-less token per line, which would shadow the word-level change
// segments the diff already computes. Better to keep those.
if (!ext || diffLines.length === 0 || !isLanguageSupported(`x.${ext}`)) {
return diffLines;
}
const oldCode: string[] = [];
const newCode: string[] = [];
const positions = diffLines.map((line) => {
const code = diffLineCode(line);
if (line.type === "context") {
const position = { oldIndex: oldCode.length, newIndex: newCode.length };
oldCode.push(code);
newCode.push(code);
return position;
}
if (line.type === "remove") {
const position = { oldIndex: oldCode.length, newIndex: -1 };
oldCode.push(code);
return position;
}
if (line.type === "add") {
const position = { oldIndex: -1, newIndex: newCode.length };
newCode.push(code);
return position;
}
return { oldIndex: -1, newIndex: -1 };
});
const oldTokens = oldCode.length > 0 ? tokenizeToLines(oldCode.join("\n"), ext) : null;
const newTokens = newCode.length > 0 ? tokenizeToLines(newCode.join("\n"), ext) : null;
if (!oldTokens && !newTokens) {
return diffLines;
}
return diffLines.map((line, index) => {
const { oldIndex, newIndex } = positions[index];
let tokens: HighlightToken[] | undefined;
if ((line.type === "add" || line.type === "context") && newTokens) {
tokens = newTokens[newIndex];
} else if (line.type === "remove" && oldTokens) {
tokens = oldTokens[oldIndex];
}
return tokens ? { ...line, tokens } : line;
});
}
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import {
extensionFromPath,
highlightToKeyedLines,
MAX_HIGHLIGHT_CHARS,
tokenizeToLines,
} from "./highlight-cache";
describe("extensionFromPath", () => {
it("extracts a lowercased extension regardless of absolute/relative path", () => {
expect(extensionFromPath("/repo/src/Index.TS")).toBe("ts");
expect(extensionFromPath("src/index.ts")).toBe("ts");
expect(extensionFromPath("a.b/c.tsx")).toBe("tsx");
});
it("returns null for paths without a usable extension", () => {
expect(extensionFromPath(null)).toBeNull();
expect(extensionFromPath(undefined)).toBeNull();
expect(extensionFromPath("Makefile")).toBeNull();
expect(extensionFromPath(".gitignore")).toBeNull();
expect(extensionFromPath("trailingdot.")).toBeNull();
});
});
describe("tokenizeToLines", () => {
it("returns one token array per line for a supported language", () => {
const lines = tokenizeToLines("const a = 1;\nconst b = 2;", "ts");
expect(lines).not.toBeNull();
expect(lines).toHaveLength(2);
expect(lines?.[0].some((token) => token.style === "keyword")).toBe(true);
});
it("returns null when there is no extension", () => {
expect(tokenizeToLines("whatever", null)).toBeNull();
});
it("falls back to style-less per-line tokens for an unknown extension", () => {
const lines = tokenizeToLines("line one\nline two", "unknownext");
expect(lines).toHaveLength(2);
expect(lines?.[0]).toEqual([{ text: "line one", style: null }]);
});
it("returns null above the size cap so callers fall back to plain text", () => {
const huge = "x".repeat(MAX_HIGHLIGHT_CHARS + 1);
expect(tokenizeToLines(huge, "ts")).toBeNull();
});
it("serves a cached result on repeat calls (identity-stable)", () => {
const first = tokenizeToLines("const cached = true;", "ts");
const second = tokenizeToLines("const cached = true;", "ts");
expect(first).toBe(second);
});
});
describe("highlightToKeyedLines", () => {
it("produces stable keys for lines and tokens", () => {
const keyed = highlightToKeyedLines("const a = 1;", "ts");
expect(keyed?.[0].key).toBe("line-0");
expect(keyed?.[0].tokens[0].key).toBe("0-0");
});
it("returns null when highlighting is unavailable", () => {
expect(highlightToKeyedLines("text", null)).toBeNull();
});
});
+89
View File
@@ -0,0 +1,89 @@
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
// Shared, theme-independent tokenization + cache for syntax highlighting.
// Used by markdown code blocks, file preview, and tool-call detail blocks
// (Edit diff / Write / Read). Colors are applied at render time, so the cache
// key is just (extension, code) and one entry serves both light and dark.
export interface KeyedToken {
key: string;
token: HighlightToken;
}
export interface KeyedLine {
key: string;
tokens: KeyedToken[];
}
// Above this, highlighting a whole document on the main thread risks a visible
// stall when a large Read/Write block is expanded. Callers fall back to plain
// monospace text. Generous enough to cover the vast majority of real blocks.
export const MAX_HIGHLIGHT_CHARS = 100_000;
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, HighlightToken[][]>(200);
// Tokenize `code` to per-line tokens, cached. Returns null when the language is
// unsupported, the input is over the size cap, or parsing throws — callers then
// render plain text.
export function tokenizeToLines(code: string, ext: string | null): HighlightToken[][] | null {
if (!ext) return null;
if (code.length > MAX_HIGHLIGHT_CHARS) return null;
const cacheKey = `${ext}:${code}`;
const cached = tokenizationCache.get(cacheKey);
if (cached) return cached;
let lines: HighlightToken[][];
try {
lines = highlightCode(code, `x.${ext}`);
} catch {
return null;
}
tokenizationCache.set(cacheKey, lines);
return lines;
}
function toKeyedLine(tokens: HighlightToken[], lineIndex: number): KeyedLine {
return {
key: `line-${lineIndex}`,
tokens: tokens.map((token, tokenIndex) => ({
key: `${lineIndex}-${tokenIndex}`,
token,
})),
};
}
export function highlightToKeyedLines(code: string, ext: string | null): KeyedLine[] | null {
const lines = tokenizeToLines(code, ext);
return lines ? lines.map(toKeyedLine) : null;
}
// Extension for grammar selection from a file path. We only need the suffix —
// absolute vs relative paths are equivalent here.
export function extensionFromPath(filePath: string | null | undefined): string | null {
if (!filePath) return null;
const name = filePath.split(/[\\/]/).pop() ?? filePath;
const dot = name.lastIndexOf(".");
if (dot <= 0 || dot === name.length - 1) return null;
return name.slice(dot + 1).toLowerCase();
}
@@ -1,4 +1,5 @@
import { z } from "zod";
import type { HighlightToken } from "@getpaseo/highlight";
export interface DiffSegment {
text: string;
@@ -9,6 +10,9 @@ export interface DiffLine {
type: "add" | "remove" | "context" | "header";
content: string;
segments?: DiffSegment[];
// Syntax-highlight tokens for the code on this line (prefix char excluded),
// attached by highlightDiffLines when the file's language is supported.
tokens?: HighlightToken[];
}
function splitIntoLines(text: string): string[] {