diff --git a/README.md b/README.md
index eb38fa83..d98388b3 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,7 @@ The whole project was built entirely with AI coding agents under my supervision.



+

@@ -41,7 +42,7 @@ The whole project was built entirely with AI coding agents under my supervision.
- Rich permission cards with syntax-highlighted operation previews
- Smart tool visualization (inline diffs, file trees, results highlighting)
- Per-agent permission mode control (ask, allow, full) adjustable per-session
-- Familiar diff viewer like you're used to in VSCode
+- Beautiful diff viewer with syntax highlighting, line wrap, and responsive layout
- Built-in OpenCode agent/command management
## Installation
@@ -97,6 +98,7 @@ Independent project, not affiliated with OpenCode team.
- [OpenCode](https://opencode.ai) - For the excellent API and extensible architecture
- [Flexoki](https://github.com/kepano/flexoki) - Beautiful color scheme by [Steph Ango](https://stephango.com/flexoki)
+- [Pierre](https://pierrejs-docs.vercel.app/) - Fast, beautiful diff viewer with syntax highlighting
- [Tauri](https://github.com/tauri-apps/tauri) - Desktop application framework
- [David Hill](https://x.com/iamdavidhill) - who inspired me to release this without [overthinking](https://x.com/iamdavidhill/status/1993648326450020746?s=20)
- My wife, who created a beautiful firework animation for the app while testing it for the first time
diff --git a/docs/references/diff_example.png b/docs/references/diff_example.png
new file mode 100644
index 00000000..d09bc446
Binary files /dev/null and b/docs/references/diff_example.png differ
diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock
index 7890db71..303a4b4e 100644
--- a/packages/desktop/src-tauri/Cargo.lock
+++ b/packages/desktop/src-tauri/Cargo.lock
@@ -2847,7 +2847,7 @@ dependencies = [
[[package]]
name = "openchamber-desktop"
-version = "1.1.0"
+version = "1.1.2"
dependencies = [
"anyhow",
"axum",
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 44aa69fd..2d03e5e4 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -15,6 +15,7 @@
"@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1",
"@opencode-ai/sdk": "^1.0.65",
+ "@pierre/diffs": "^1.0.0",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -38,7 +39,6 @@
"node-pty": "^1.0.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
- "@monaco-editor/react": "^4.6.0",
"react-syntax-highlighter": "^15.6.6",
"simple-git": "^3.28.0",
"sonner": "^2.0.7",
diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx
index 474f2cbf..3233d964 100644
--- a/packages/ui/src/components/layout/MainLayout.tsx
+++ b/packages/ui/src/components/layout/MainLayout.tsx
@@ -8,6 +8,7 @@ import { HelpDialog } from '../ui/HelpDialog';
import { SessionSidebar } from '@/components/session/SessionSidebar';
import { SessionDialogs } from '@/components/session/SessionDialogs';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
+import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
@@ -58,7 +59,6 @@ export const MainLayout: React.FC = () => {
let timeoutId: number | undefined;
const handleResize = () => {
-
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
@@ -94,19 +94,20 @@ export const MainLayout: React.FC = () => {
const isChatActive = activeMainTab === 'chat';
return (
-
-
-
-
-
setSettingsDialogOpen(false)} />
+
+
+
+
+
+
setSettingsDialogOpen(false)} />
- {isMobile ? (
+ {isMobile ? (
<>
@@ -157,5 +158,6 @@ export const MainLayout: React.FC = () => {
+
);
};
diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx
index c792885b..54760b07 100644
--- a/packages/ui/src/components/views/DiffView.tsx
+++ b/packages/ui/src/components/views/DiffView.tsx
@@ -1,12 +1,11 @@
import React from 'react';
-import { RiGitCommitLine, RiLoader4Line } from '@remixicon/react';
+import { RiGitCommitLine, RiLoader4Line, RiTextWrap } from '@remixicon/react';
import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useUIStore } from '@/stores/useUIStore';
import { useGitStore, useGitStatus, useIsGitRepo, useGitFileCount } from '@/stores/useGitStore';
import type { GitStatus } from '@/lib/api/types';
-import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
@@ -15,15 +14,15 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { RiArrowDownSLine } from '@remixicon/react';
-import { toast } from 'sonner';
import { getLanguageFromExtension } from '@/lib/toolHelpers';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { DiffViewToggle } from '@/components/chat/message/DiffViewToggle';
import type { DiffViewMode } from '@/components/chat/message/types';
+import { PierreDiffViewer } from './PierreDiffViewer';
+import { useDeviceInfo } from '@/lib/device';
-const LazyMonacoDiffViewer = React.lazy(() =>
- import('./MonacoDiffViewer').then((mod) => ({ default: mod.MonacoDiffViewer }))
-);
+// Minimum width for side-by-side diff view (px)
+const SIDE_BY_SIDE_MIN_WIDTH = 1100;
type FileEntry = GitStatus['files'][number] & {
insertions: number;
@@ -31,9 +30,10 @@ type FileEntry = GitStatus['files'][number] & {
isNew: boolean;
};
+type DiffData = { original: string; modified: string };
+
const isNewStatusFile = (file: GitStatus['files'][number]): boolean => {
const { index, working_dir: workingDir } = file;
-
return index === 'A' || workingDir === 'A' || index === '?' || workingDir === '?';
};
@@ -43,12 +43,8 @@ const formatDiffTotals = (insertions?: number, deletions?: number) => {
if (!added && !removed) return null;
return (
- {added ? (
- +{added}
- ) : null}
- {removed ? (
- -{removed}
- ) : null}
+ {added ? +{added} : null}
+ {removed ? -{removed} : null}
);
};
@@ -74,9 +70,7 @@ const FileSelector = React.memo(({
{selectedFileEntry ? (
-
- {selectedFileEntry.path}
-
+ {selectedFileEntry.path}
{formatDiffTotals(selectedFileEntry.insertions, selectedFileEntry.deletions)}
) : (
@@ -90,9 +84,7 @@ const FileSelector = React.memo(({
{changedFiles.map((file) => (
-
- {file.path}
-
+ {file.path}
{formatDiffTotals(file.insertions, file.deletions)}
@@ -103,76 +95,54 @@ const FileSelector = React.memo(({
);
});
-interface DiffContentProps {
- fileDiff: { original: string; modified: string } | null;
- activeFilePath: string;
- isDiffLoading: boolean;
- diffError: string | null;
- onRetry: () => void;
+// Single diff viewer instance - stays mounted
+interface SingleDiffViewerProps {
+ filePath: string;
+ diff: DiffData;
+ isVisible: boolean;
renderSideBySide: boolean;
- allowResponsive: boolean;
+ wrapLines: boolean;
}
-const DiffContent = React.memo(({
- fileDiff,
- activeFilePath,
- isDiffLoading,
- diffError,
- onRetry,
+const SingleDiffViewer = React.memo(({
+ filePath,
+ diff,
+ isVisible,
renderSideBySide,
- allowResponsive,
+ wrapLines,
}) => {
const language = React.useMemo(
- () => (activeFilePath ? getLanguageFromExtension(activeFilePath) || 'text' : 'text'),
- [activeFilePath]
+ () => getLanguageFromExtension(filePath) || 'text',
+ [filePath]
);
- if (isDiffLoading) {
+ // Use display:none for hidden diffs to exclude from layout calculations during resize
+ // This is faster for resize than visibility:hidden which keeps elements in layout flow
+ if (!isVisible) {
return (
-
-
- Loading diff…
-
- );
- }
-
- if (diffError) {
- return (
-
-
{diffError}
-
- Retry
-
-
- );
- }
-
- if (!fileDiff || (!fileDiff.original && !fileDiff.modified)) {
- return (
-
- No changes detected for this file
+
);
}
return (
-
-
-
- Loading diff viewer…
-
- )}
- >
-
-
+
);
});
@@ -191,6 +161,7 @@ const useEffectiveDirectory = () => {
export const DiffView: React.FC = () => {
const { git } = useRuntimeAPIs();
const effectiveDirectory = useEffectiveDirectory();
+ const { screenWidth } = useDeviceInfo();
const isGitRepo = useIsGitRepo(effectiveDirectory ?? null);
const status = useGitStatus(effectiveDirectory ?? null);
@@ -198,73 +169,21 @@ export const DiffView: React.FC = () => {
const { setActiveDirectory, fetchStatus, setDiff } = useGitStore();
const [selectedFile, setSelectedFile] = React.useState
(null);
- const selectedFileRef = React.useRef(null);
-
- const [isDiffLoading, setIsDiffLoading] = React.useState(false);
- const [diffError, setDiffError] = React.useState(null);
- const [fileDiff, setFileDiff] = React.useState<{ original: string; modified: string } | null>(null);
+ const [allDiffs, setAllDiffs] = React.useState>(new Map());
+ const [loadingFiles, setLoadingFiles] = React.useState>(new Set());
const pendingDiffFile = useUIStore((state) => state.pendingDiffFile);
const setPendingDiffFile = useUIStore((state) => state.setPendingDiffFile);
const diffLayoutPreference = useUIStore((state) => state.diffLayoutPreference);
const diffFileLayout = useUIStore((state) => state.diffFileLayout);
const setDiffFileLayout = useUIStore((state) => state.setDiffFileLayout);
+ const diffWrapLines = useUIStore((state) => state.diffWrapLines);
+ const setDiffWrapLines = useUIStore((state) => state.setDiffWrapLines);
const lastStatusChange = useGitStore(React.useCallback((state) => {
if (!effectiveDirectory) return 0;
return state.directories.get(effectiveDirectory)?.lastStatusChange ?? 0;
}, [effectiveDirectory]));
- const getCachedDiffIfFresh = React.useCallback((directory: string | undefined, filePath: string) => {
- if (!directory) return null;
- const dirState = useGitStore.getState().directories.get(directory);
- if (!dirState) return null;
- const cached = dirState.diffCache.get(filePath);
- if (!cached) return null;
-
- if (cached.fetchedAt < (dirState.lastStatusChange || 0)) {
- return null;
- }
- return cached;
- }, []);
-
- const handleSelectFile = React.useCallback((value: string) => {
- selectedFileRef.current = value;
- setSelectedFile(value);
- setDiffError(null);
-
- const cached = getCachedDiffIfFresh(effectiveDirectory, value);
- if (cached) {
- setFileDiff({ original: cached.original, modified: cached.modified });
- setIsDiffLoading(false);
- } else {
- setFileDiff(null);
- setIsDiffLoading(true);
- }
- }, [effectiveDirectory, getCachedDiffIfFresh]);
-
- React.useEffect(() => {
- if (effectiveDirectory) {
- setActiveDirectory(effectiveDirectory);
-
- const dirState = useGitStore.getState().directories.get(effectiveDirectory);
- if (!dirState?.status) {
- fetchStatus(effectiveDirectory, git);
- }
- }
- }, [effectiveDirectory, setActiveDirectory, fetchStatus, git]);
-
- React.useEffect(() => {
- if (!pendingDiffFile) return;
-
- handleSelectFile(pendingDiffFile);
-
- setPendingDiffFile(null);
- }, [pendingDiffFile, handleSelectFile, setPendingDiffFile]);
-
- React.useEffect(() => {
- selectedFileRef.current = selectedFile;
- }, [selectedFile]);
-
const changedFiles: FileEntry[] = React.useMemo(() => {
if (!status?.files) return [];
const diffStats = status.diffStats ?? {};
@@ -287,81 +206,205 @@ export const DiffView: React.FC = () => {
const currentLayoutForSelectedFile = React.useMemo<'inline' | 'side-by-side' | null>(() => {
if (!selectedFileEntry) return null;
+ // Per-file override takes priority
const override = diffFileLayout[selectedFileEntry.path];
if (override) return override;
- if (diffLayoutPreference === 'inline' || diffLayoutPreference === 'side-by-side') {
- return diffLayoutPreference;
+ // Explicit user preference - respect it regardless of screen width
+ if (diffLayoutPreference === 'inline') {
+ return 'inline';
}
- return selectedFileEntry.isNew ? 'inline' : 'side-by-side';
- }, [selectedFileEntry, diffFileLayout, diffLayoutPreference]);
+ if (diffLayoutPreference === 'side-by-side') {
+ return 'side-by-side';
+ }
+ // Dynamic mode: auto-switch based on file type and screen width
+ const isNarrow = screenWidth < SIDE_BY_SIDE_MIN_WIDTH;
+ if (selectedFileEntry.isNew || isNarrow) {
+ return 'inline';
+ }
+
+ return 'side-by-side';
+ }, [selectedFileEntry, diffFileLayout, diffLayoutPreference, screenWidth]);
+
+ // Fetch git status on mount
React.useEffect(() => {
- if (!selectedFile && !pendingDiffFile && changedFiles.length > 0) {
- const nextPath = changedFiles[0].path;
- handleSelectFile(nextPath);
+ if (effectiveDirectory) {
+ setActiveDirectory(effectiveDirectory);
+ const dirState = useGitStore.getState().directories.get(effectiveDirectory);
+ if (!dirState?.status) {
+ fetchStatus(effectiveDirectory, git);
+ }
}
- }, [changedFiles, selectedFile, pendingDiffFile, handleSelectFile]);
+ }, [effectiveDirectory, setActiveDirectory, fetchStatus, git]);
+ // Handle pending diff file from external navigation
+ React.useEffect(() => {
+ if (pendingDiffFile) {
+ setSelectedFile(pendingDiffFile);
+ setPendingDiffFile(null);
+ }
+ }, [pendingDiffFile, setPendingDiffFile]);
+
+ // Auto-select first file
+ React.useEffect(() => {
+ if (!selectedFile && changedFiles.length > 0) {
+ setSelectedFile(changedFiles[0].path);
+ }
+ }, [changedFiles, selectedFile]);
+
+ // Clear selection if file no longer exists
React.useEffect(() => {
if (selectedFile && changedFiles.length > 0) {
const stillExists = changedFiles.some((f) => f.path === selectedFile);
if (!stillExists) {
- setSelectedFile(null);
- selectedFileRef.current = null;
- setFileDiff(null);
- setDiffError(null);
+ setSelectedFile(changedFiles[0]?.path ?? null);
}
}
}, [changedFiles, selectedFile]);
- const loadDiff = React.useCallback(async () => {
- if (!effectiveDirectory || !selectedFileEntry) {
- setFileDiff(null);
- setDiffError(null);
- setIsDiffLoading(false);
- return;
- }
+ // PRE-FETCH ALL DIFFS when changedFiles changes
+ React.useEffect(() => {
+ if (!effectiveDirectory || changedFiles.length === 0) return;
- const cacheKey = selectedFileEntry.path;
+ const fetchAllDiffs = async () => {
+ const filesToFetch = changedFiles.filter((file) => !allDiffs.has(file.path));
+ if (filesToFetch.length === 0) return;
- const cached = getCachedDiffIfFresh(effectiveDirectory, cacheKey);
- if (cached) {
- setFileDiff({ original: cached.original, modified: cached.modified });
- setDiffError(null);
- setIsDiffLoading(false);
- return;
- }
-
- setIsDiffLoading(true);
- setDiffError(null);
-
- try {
- const response = await git.getGitFileDiff(effectiveDirectory, {
- path: selectedFileEntry.path,
+ // Mark all as loading
+ setLoadingFiles((prev) => {
+ const next = new Set(prev);
+ filesToFetch.forEach((f) => next.add(f.path));
+ return next;
});
- const diff = {
- original: response.original ?? '',
- modified: response.modified ?? '',
- };
- setDiff(effectiveDirectory, cacheKey, diff);
- setFileDiff(diff);
- } catch (error) {
- const message = error instanceof Error ? error.message : 'Failed to load diff';
- setDiffError(message);
- toast.error(message);
- } finally {
- setIsDiffLoading(false);
- }
- }, [effectiveDirectory, git, selectedFileEntry, getCachedDiffIfFresh, setDiff, lastStatusChange]);
+ // Fetch all in parallel
+ const results = await Promise.allSettled(
+ filesToFetch.map(async (file) => {
+ const dirState = useGitStore.getState().directories.get(effectiveDirectory);
+ const cached = dirState?.diffCache.get(file.path);
+ if (cached && cached.fetchedAt >= (dirState?.lastStatusChange || 0)) {
+ return { path: file.path, diff: { original: cached.original, modified: cached.modified } };
+ }
+ const response = await git.getGitFileDiff(effectiveDirectory, { path: file.path });
+ const diff = { original: response.original ?? '', modified: response.modified ?? '' };
+ setDiff(effectiveDirectory, file.path, diff);
+ return { path: file.path, diff };
+ })
+ );
+
+ // Update state with all fetched diffs
+ setAllDiffs((prev) => {
+ const next = new Map(prev);
+ results.forEach((result) => {
+ if (result.status === 'fulfilled') {
+ next.set(result.value.path, result.value.diff);
+ }
+ });
+ return next;
+ });
+
+ // Clear loading state
+ setLoadingFiles((prev) => {
+ const next = new Set(prev);
+ filesToFetch.forEach((f) => next.delete(f.path));
+ return next;
+ });
+ };
+
+ fetchAllDiffs();
+ }, [effectiveDirectory, changedFiles, git, setDiff]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Clear all diffs when directory changes
React.useEffect(() => {
- loadDiff();
- }, [loadDiff]);
+ setAllDiffs(new Map());
+ setLoadingFiles(new Set());
+ }, [effectiveDirectory]);
- const activeFilePath = selectedFileEntry?.path ?? '';
+ // Re-fetch stale diffs when status changes (don't clear - keep old ones visible while fetching)
+ React.useEffect(() => {
+ if (!effectiveDirectory || !lastStatusChange || changedFiles.length === 0) return;
+
+ const refetchStaleDiffs = async () => {
+ const dirState = useGitStore.getState().directories.get(effectiveDirectory);
+ if (!dirState) return;
+
+ // Find files that need refetching (stale cache or no cache)
+ const staleFiles = changedFiles.filter((file) => {
+ const cached = dirState.diffCache.get(file.path);
+ return !cached || cached.fetchedAt < lastStatusChange;
+ });
+
+ if (staleFiles.length === 0) return;
+
+ // Mark as loading
+ setLoadingFiles((prev) => {
+ const next = new Set(prev);
+ staleFiles.forEach((f) => next.add(f.path));
+ return next;
+ });
+
+ // Fetch in parallel
+ const results = await Promise.allSettled(
+ staleFiles.map(async (file) => {
+ const response = await git.getGitFileDiff(effectiveDirectory, { path: file.path });
+ const diff = { original: response.original ?? '', modified: response.modified ?? '' };
+ setDiff(effectiveDirectory, file.path, diff);
+ return { path: file.path, diff };
+ })
+ );
+
+ // Update diffs in place (don't clear old ones first)
+ setAllDiffs((prev) => {
+ const next = new Map(prev);
+ results.forEach((result) => {
+ if (result.status === 'fulfilled') {
+ next.set(result.value.path, result.value.diff);
+ }
+ });
+ // Remove diffs for files that no longer exist in changedFiles
+ for (const [filePath] of prev) {
+ if (!changedFiles.some((f) => f.path === filePath)) {
+ next.delete(filePath);
+ }
+ }
+ return next;
+ });
+
+ // Clear loading state
+ setLoadingFiles((prev) => {
+ const next = new Set(prev);
+ staleFiles.forEach((f) => next.delete(f.path));
+ return next;
+ });
+ };
+
+ refetchStaleDiffs();
+ }, [effectiveDirectory, lastStatusChange, changedFiles, git, setDiff]);
+
+ const handleSelectFile = React.useCallback((value: string) => {
+ setSelectedFile(value);
+ }, []);
+
+ const renderSideBySide = (currentLayoutForSelectedFile ?? 'side-by-side') === 'side-by-side';
+
+ // Render all diff viewers - they stay mounted
+ const renderAllDiffViewers = () => {
+ if (allDiffs.size === 0) return null;
+
+ return Array.from(allDiffs.entries()).map(([filePath, diff]) => (
+
+ ));
+ };
const renderContent = () => {
if (!effectiveDirectory) {
@@ -397,31 +440,18 @@ export const DiffView: React.FC = () => {
);
}
- if (!selectedFileEntry) {
- return (
-
- Select a file to inspect its diff
-
- );
- }
-
- const effectiveLayout = currentLayoutForSelectedFile ?? 'side-by-side';
- const renderSideBySide = effectiveLayout === 'side-by-side';
- const hasFileOverride = !!diffFileLayout[selectedFileEntry.path];
- const allowResponsive =
- diffLayoutPreference === 'dynamic' && !hasFileOverride;
+ const isCurrentFileLoading = selectedFile && loadingFiles.has(selectedFile);
+ const hasCurrentDiff = selectedFile && allDiffs.has(selectedFile);
return (
-
-
+
+ {renderAllDiffViewers()}
+ {isCurrentFileLoading && !hasCurrentDiff && (
+
+
+ Loading diff…
+
+ )}
);
};
@@ -444,6 +474,20 @@ export const DiffView: React.FC = () => {
onSelectFile={handleSelectFile}
/>
+ {selectedFileEntry && (
+
setDiffWrapLines(!diffWrapLines)}
+ className={`flex items-center justify-center size-5 rounded-sm transition-opacity ${
+ diffWrapLines
+ ? 'text-foreground opacity-100'
+ : 'text-muted-foreground opacity-60 hover:opacity-100'
+ }`}
+ title={diffWrapLines ? 'Disable line wrap' : 'Enable line wrap'}
+ >
+
+
+ )}
{selectedFileEntry && currentLayoutForSelectedFile && (
= ({
- original,
- modified,
- language,
- renderSideBySide,
- allowResponsive = false,
- readOnly = true,
- showLineNumbers = true,
-}) => {
- const { currentTheme } = useThemeSystem();
- const diffEditorRef = useRef(null);
-
- const themeId = useMemo(
- () => getMonacoThemeIdForTheme(currentTheme),
- [currentTheme],
- );
-
- const handleBeforeMount = useCallback>(
- (monacoInstance) => {
- ensureMonacoThemeRegistered(
- currentTheme,
- monacoInstance as Parameters[1],
- );
- },
- [currentTheme],
- );
-
- const options = useMemo(
- (): DiffEditorProps['options'] => ({
- readOnly,
- renderSideBySide,
- automaticLayout: true,
- minimap: { enabled: false },
- wordWrap: 'off',
- lineNumbers: showLineNumbers ? 'on' : 'off',
- renderIndicators: false,
- scrollBeyondLastLine: false,
- renderLineHighlight: 'none',
- selectionHighlight: false,
- occurrencesHighlight: 'off',
- roundedSelection: false,
- contextmenu: false,
- folding: false,
- glyphMargin: false,
- renderMarginRevertIcon: false,
- scrollbar: {
- vertical: 'visible',
- horizontal: 'visible',
- verticalScrollbarSize: 6,
- horizontalScrollbarSize: 6,
- useShadows: false,
- },
- hover: {
- enabled: false,
- },
- overviewRulerLanes: 1,
-
- useInlineViewWhenSpaceIsLimited: allowResponsive,
- }),
- [allowResponsive, readOnly, renderSideBySide, showLineNumbers],
- );
-
- useEffect(() => {
- if (typeof window === 'undefined') return;
- const monaco = (window as typeof window & { monaco?: { editor: { setTheme: (id: string) => void } } }).monaco;
- if (!monaco?.editor?.setTheme) return;
-
- ensureMonacoThemeRegistered(currentTheme);
- monaco.editor.setTheme(themeId);
- }, [currentTheme, themeId]);
-
- useEffect(() => {
- const diffEditor = diffEditorRef.current;
- if (!diffEditor) return;
-
- const originalEditor = diffEditor.getOriginalEditor();
- const modifiedEditor = diffEditor.getModifiedEditor();
-
- const disablePopups = {
- hover: { enabled: false },
- quickSuggestions: false,
- parameterHints: { enabled: false },
- suggestOnTriggerCharacters: false,
- } as const;
-
- originalEditor.updateOptions(disablePopups);
- modifiedEditor.updateOptions(disablePopups);
- }, [themeId, readOnly, renderSideBySide]);
-
- useEffect(() => {
- return () => {
- const diffEditor = diffEditorRef.current;
- if (diffEditor) {
- try {
- diffEditor.dispose?.();
- } catch (error) {
- console.warn('Error disposing DiffEditor:', error);
- }
- }
- diffEditorRef.current = null;
- };
- }, []);
-
- if (typeof window === 'undefined') {
- return null;
- }
-
- return (
- {
- diffEditorRef.current = diffEditor;
- const original = diffEditor.getOriginalEditor();
- const modified = diffEditor.getModifiedEditor();
- const disablePopups = {
- hover: { enabled: false },
- quickSuggestions: false,
- parameterHints: { enabled: false },
- suggestOnTriggerCharacters: false,
- } as const;
- original.updateOptions(disablePopups);
- modified.updateOptions(disablePopups);
- }}
- loading={null}
- className="monaco-diff-wrapper"
- />
- );
-};
diff --git a/packages/ui/src/components/views/PierreDiffViewer.tsx b/packages/ui/src/components/views/PierreDiffViewer.tsx
new file mode 100644
index 00000000..06e4a8a0
--- /dev/null
+++ b/packages/ui/src/components/views/PierreDiffViewer.tsx
@@ -0,0 +1,123 @@
+import React, { useMemo, useRef } from 'react';
+import { FileDiff } from '@pierre/diffs/react';
+import { parseDiffFromFile, type FileContents, type FileDiffMetadata } from '@pierre/diffs';
+
+import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
+import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
+
+interface PierreDiffViewerProps {
+ original: string;
+ modified: string;
+ language: string;
+ fileName?: string;
+ renderSideBySide: boolean;
+ wrapLines?: boolean;
+}
+
+// CSS injected into Pierre's Shadow DOM for WebKit scroll optimization
+// Note: avoid will-change and contain:paint as they break resize behavior
+const WEBKIT_SCROLL_FIX_CSS = `
+ :host, pre, [data-diffs], [data-code] {
+ transform: translateZ(0);
+ -webkit-transform: translateZ(0);
+ -webkit-backface-visibility: hidden;
+ backface-visibility: hidden;
+ }
+ [data-code] {
+ -webkit-overflow-scrolling: touch;
+ }
+`;
+
+// Fast cache key - use length + samples instead of full hash
+function getCacheKey(fileName: string, original: string, modified: string): string {
+ // Sample a few characters instead of hashing entire content
+ const sampleOriginal = original.length > 100
+ ? `${original.slice(0, 50)}${original.slice(-50)}`
+ : original;
+ const sampleModified = modified.length > 100
+ ? `${modified.slice(0, 50)}${modified.slice(-50)}`
+ : modified;
+ return `${fileName}:${original.length}:${modified.length}:${sampleOriginal.length}:${sampleModified.length}`;
+}
+
+export const PierreDiffViewer: React.FC = ({
+ original,
+ modified,
+ language,
+ fileName = 'file',
+ renderSideBySide,
+ wrapLines = false,
+}) => {
+ const themeSystem = useOptionalThemeSystem();
+ const isDark = themeSystem?.currentTheme?.metadata?.variant === 'dark';
+
+ // Cache the last computed diff to avoid recomputing on every render
+ const diffCacheRef = useRef<{
+ key: string;
+ fileDiff: FileDiffMetadata;
+ } | null>(null);
+
+ // Pre-parse the diff with cacheKey for worker pool caching
+ const fileDiff = useMemo(() => {
+ const cacheKey = getCacheKey(fileName, original, modified);
+
+ // Return cached diff if inputs haven't changed
+ if (diffCacheRef.current?.key === cacheKey) {
+ return diffCacheRef.current.fileDiff;
+ }
+
+ const oldFile: FileContents = {
+ name: fileName,
+ contents: original,
+ lang: language as FileContents['lang'],
+ cacheKey: `old-${cacheKey}`,
+ };
+
+ const newFile: FileContents = {
+ name: fileName,
+ contents: modified,
+ lang: language as FileContents['lang'],
+ cacheKey: `new-${cacheKey}`,
+ };
+
+ const diff = parseDiffFromFile(oldFile, newFile);
+
+ // Cache the result
+ diffCacheRef.current = { key: cacheKey, fileDiff: diff };
+
+ return diff;
+ }, [fileName, original, modified, language]);
+
+ const options = useMemo(() => ({
+ theme: {
+ dark: 'vitesse-dark' as const,
+ light: 'vitesse-light' as const,
+ },
+ themeType: isDark ? ('dark' as const) : ('light' as const),
+ diffStyle: renderSideBySide ? ('split' as const) : ('unified' as const),
+ diffIndicators: 'none' as const,
+ hunkSeparators: 'line-info' as const,
+ lineDiffType: 'word-alt' as const,
+ overflow: wrapLines ? ('wrap' as const) : ('scroll' as const),
+ disableFileHeader: true,
+ enableLineSelection: false,
+ enableHoverUtility: false,
+ unsafeCSS: WEBKIT_SCROLL_FIX_CSS,
+ }), [isDark, renderSideBySide, wrapLines]);
+
+ if (typeof window === 'undefined') {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/ui/src/contexts/DiffWorkerProvider.tsx b/packages/ui/src/contexts/DiffWorkerProvider.tsx
new file mode 100644
index 00000000..c6f4a3f3
--- /dev/null
+++ b/packages/ui/src/contexts/DiffWorkerProvider.tsx
@@ -0,0 +1,47 @@
+import React, { useMemo } from 'react';
+import { WorkerPoolContextProvider, useWorkerPool } from '@pierre/diffs/react';
+import type { SupportedLanguages } from '@pierre/diffs';
+
+import { useOptionalThemeSystem } from './useThemeSystem';
+import { workerFactory } from '@/lib/diff/workerFactory';
+
+// Only preload the most common languages - others load on demand
+const PRELOAD_LANGS: SupportedLanguages[] = [
+ 'typescript',
+ 'javascript',
+ 'tsx',
+ 'json',
+];
+
+interface DiffWorkerProviderProps {
+ children: React.ReactNode;
+}
+
+export const DiffWorkerProvider: React.FC = ({ children }) => {
+ const themeSystem = useOptionalThemeSystem();
+ const isDark = themeSystem?.currentTheme?.metadata?.variant === 'dark';
+
+ const highlighterOptions = useMemo(() => ({
+ theme: {
+ dark: 'vitesse-dark' as const,
+ light: 'vitesse-light' as const,
+ },
+ themeType: isDark ? ('dark' as const) : ('light' as const),
+ langs: PRELOAD_LANGS,
+ }), [isDark]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export { useWorkerPool };
diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css
index d88439fa..329ba9c8 100644
--- a/packages/ui/src/index.css
+++ b/packages/ui/src/index.css
@@ -706,49 +706,34 @@ html:not(.dark) .chat-scroll {
margin: 0;
}
-/* Monaco diff editor scrollbar styling (match overlay look) */
-.monaco-diff-wrapper {
- --vscode-scrollbarSlider-background: var(--oc-scrollbar-thumb);
- --vscode-scrollbarSlider-hoverBackground: var(--oc-scrollbar-thumb-hover);
- --vscode-scrollbarSlider-activeBackground: var(--oc-scrollbar-thumb-hover);
- --vscode-scrollbar-background: transparent;
- --vscode-scrollbar-shadow: transparent;
+/* Pierre diff viewer styling */
+.pierre-diff-wrapper {
+ --diffs-font-family: var(--font-mono, 'IBM Plex Mono', monospace);
+ --diffs-font-size: var(--text-code, 13px);
+ --diffs-line-height: 1.5;
+ --diffs-tab-size: 2;
+ --diffs-header-font-family: var(--font-sans, 'IBM Plex Sans', sans-serif);
+ --diffs-min-number-column-width: 4ch;
+ --diffs-gap-inline: 0;
+ --diffs-gap-block: 0;
}
-.monaco-diff-wrapper .monaco-scrollable-element > .scrollbar {
- background: transparent;
+/* WebKit scroll rendering optimizations */
+/* Note: avoid will-change and contain as they break resize behavior */
+.pierre-diff-wrapper,
+.pierre-diff-wrapper diffs-container {
+ /* Force GPU compositing layer */
+ transform: translateZ(0);
+ -webkit-transform: translateZ(0);
+ -webkit-backface-visibility: hidden;
+ backface-visibility: hidden;
+
+ /* WebKit touch scroll optimization */
+ -webkit-overflow-scrolling: touch;
}
-.monaco-diff-wrapper .monaco-scrollable-element > .scrollbar > .slider {
- border-radius: 9999px;
-}
-
-.monaco-diff-wrapper .monaco-scrollable-element > .scrollbar.vertical {
- width: 6px !important;
-}
-
-.monaco-diff-wrapper .monaco-scrollable-element > .scrollbar.horizontal {
- height: 6px !important;
-}
-
-.monaco-diff-wrapper .monaco-scrollable-element > .scrollbar.vertical > .slider {
- width: 6px !important;
-}
-
-.monaco-diff-wrapper .monaco-scrollable-element > .scrollbar.horizontal > .slider {
- height: 6px !important;
-}
-
-/* Overview ruler (change/minimap lane) width and padding */
-.monaco-diff-wrapper .decorationsOverviewRuler {
- width: 6px !important;
- right: 2px !important; /* small gap from editor edge */
-}
-
-/* Hide Monaco hover widgets globally (tooltips over buttons, etc.) */
-.monaco-hover,
-.monaco-editor .hoverWidget {
- display: none !important;
+.pierre-diff-wrapper diffs-container {
+ display: block;
}
/* Agent color system using theme variables */
diff --git a/packages/ui/src/lib/diff/workerFactory.ts b/packages/ui/src/lib/diff/workerFactory.ts
new file mode 100644
index 00000000..7387d0bf
--- /dev/null
+++ b/packages/ui/src/lib/diff/workerFactory.ts
@@ -0,0 +1,5 @@
+import WorkerUrl from '@pierre/diffs/worker/worker.js?worker&url';
+
+export function workerFactory(): Worker {
+ return new Worker(WorkerUrl, { type: 'module' });
+}
diff --git a/packages/ui/src/lib/theme/monacoThemeGenerator.ts b/packages/ui/src/lib/theme/monacoThemeGenerator.ts
deleted file mode 100644
index 41aa06d2..00000000
--- a/packages/ui/src/lib/theme/monacoThemeGenerator.ts
+++ /dev/null
@@ -1,445 +0,0 @@
-import type { Theme } from '@/types/theme';
-
-interface MonacoTokenRule {
- token: string;
- foreground?: string;
- fontStyle?: string;
-}
-
-interface MonacoThemeData {
- base: 'vs' | 'vs-dark';
- inherit: boolean;
- rules: MonacoTokenRule[];
- colors: Record;
-}
-
-type Monaco = {
- editor: {
- defineTheme: (themeName: string, themeData: MonacoThemeData) => void;
- };
-};
-
-const MONACO_LIGHT_THEME_ID = 'openchamber-flexoki-light';
-const MONACO_DARK_THEME_ID = 'openchamber-flexoki-dark';
-
-let lastRegisteredThemeId: string | null = null;
-
-const getMonacoFromGlobal = (): Monaco | null => {
- if (typeof window === 'undefined') return null;
- const anyWindow = window as typeof window & { monaco?: Monaco };
- if (anyWindow.monaco?.editor?.defineTheme) return anyWindow.monaco;
- return null;
-};
-
-const flexokiDarkTheme: MonacoThemeData = {
- base: 'vs-dark',
- inherit: true,
- rules: [
-
- { token: '', foreground: 'CECDC3' },
- { token: 'source', foreground: 'CECDC3' },
-
- { token: 'comment', foreground: '878580' },
- { token: 'comment.block', foreground: '878580' },
- { token: 'comment.line', foreground: '878580' },
- { token: 'comment.block.documentation', foreground: '575653' },
-
- { token: 'string', foreground: '3AA99F' },
- { token: 'string.quoted', foreground: '3AA99F' },
- { token: 'string.template', foreground: '3AA99F' },
- { token: 'string.regexp', foreground: '3AA99F' },
-
- { token: 'string.escape', foreground: 'CECDC3' },
- { token: 'constant.character.escape', foreground: 'CECDC3' },
-
- { token: 'number', foreground: '8B7EC8' },
- { token: 'number.hex', foreground: '8B7EC8' },
- { token: 'number.float', foreground: '8B7EC8' },
- { token: 'constant.numeric', foreground: '8B7EC8' },
-
- { token: 'constant.language', foreground: 'D0A215' },
- { token: 'constant.language.boolean', foreground: 'D0A215' },
- { token: 'constant.language.null', foreground: 'D0A215' },
-
- { token: 'keyword', foreground: '4385BE' },
- { token: 'keyword.control', foreground: '4385BE' },
- { token: 'keyword.other', foreground: '4385BE' },
-
- { token: 'keyword.control.import', foreground: 'D14D41' },
- { token: 'keyword.control.from', foreground: 'D14D41' },
- { token: 'keyword.control.export', foreground: 'D14D41' },
-
- { token: 'keyword.control.exception', foreground: 'CE5D97' },
- { token: 'keyword.control.trycatch', foreground: 'CE5D97' },
-
- { token: 'keyword.operator', foreground: 'D14D41' },
- { token: 'operator', foreground: 'D14D41' },
-
- { token: 'storage', foreground: '4385BE' },
- { token: 'storage.type', foreground: '4385BE' },
- { token: 'storage.modifier', foreground: '4385BE' },
-
- { token: 'entity.name.function', foreground: 'DA702C', fontStyle: 'bold' },
- { token: 'support.function', foreground: 'DA702C', fontStyle: 'bold' },
- { token: 'meta.function-call', foreground: 'DA702C' },
-
- { token: 'entity.name.function.method', foreground: '879A39' },
-
- { token: 'entity.name.class', foreground: 'DA702C' },
- { token: 'entity.name.type.class', foreground: 'DA702C' },
- { token: 'support.class', foreground: 'DA702C' },
-
- { token: 'entity.name.type', foreground: 'D0A215' },
- { token: 'entity.name.type.interface', foreground: 'D0A215' },
- { token: 'support.type', foreground: 'D0A215' },
-
- { token: 'entity.name.type.struct', foreground: 'DA702C' },
- { token: 'entity.name.type.enum', foreground: 'DA702C' },
-
- { token: 'entity.name.type.parameter', foreground: 'DA702C' },
-
- { token: 'variable', foreground: 'CECDC3' },
- { token: 'variable.other', foreground: 'CECDC3' },
- { token: 'variable.parameter', foreground: 'CECDC3' },
-
- { token: 'variable.other.object', foreground: '879A39' },
- { token: 'variable.other.readwrite.alias', foreground: '879A39' },
-
- { token: 'variable.language', foreground: 'CE5D97' },
- { token: 'variable.language.this', foreground: 'CE5D97' },
- { token: 'variable.language.super', foreground: 'CE5D97' },
-
- { token: 'variable.other.property', foreground: '4385BE' },
- { token: 'support.variable.property', foreground: '4385BE' },
-
- { token: 'variable.other.constant', foreground: 'CECDC3' },
-
- { token: 'meta.object-literal.key', foreground: 'DA702C' },
- { token: 'support.type.property-name', foreground: 'DA702C' },
-
- { token: 'entity.name.tag', foreground: '4385BE' },
- { token: 'tag', foreground: '4385BE' },
-
- { token: 'support.class.component', foreground: 'CE5D97' },
-
- { token: 'entity.other.attribute-name', foreground: 'D0A215' },
-
- { token: 'entity.name.namespace', foreground: 'D0A215' },
-
- { token: 'entity.name.module', foreground: 'D14D41' },
-
- { token: 'meta.decorator', foreground: 'D0A215' },
- { token: 'entity.name.function.decorator', foreground: 'D0A215' },
-
- { token: 'entity.name.label', foreground: 'CE5D97' },
-
- { token: 'meta.preprocessor', foreground: 'CE5D97' },
- { token: 'entity.name.function.preprocessor', foreground: '4385BE' },
-
- { token: 'punctuation', foreground: '878580' },
- { token: 'delimiter', foreground: '878580' },
- { token: 'delimiter.bracket', foreground: '878580' },
-
- { token: 'markup.heading', foreground: 'D0A215' },
- { token: 'markup.bold', foreground: 'D0A215', fontStyle: 'bold' },
- { token: 'markup.italic', foreground: '3AA99F', fontStyle: 'italic' },
- { token: 'markup.underline.link', foreground: '4385BE' },
- { token: 'markup.inline.raw', foreground: '3AA99F' },
-
- { token: 'invalid', foreground: 'D14D41' },
- { token: 'invalid.illegal', foreground: 'D14D41' },
-
- { token: 'string.key.json', foreground: 'DA702C' },
- { token: 'string.value.json', foreground: '3AA99F' },
-
- { token: 'support.type.property-name.css', foreground: 'CECDC3' },
- { token: 'support.constant.property-value.css', foreground: '3AA99F' },
-
- { token: 'type', foreground: 'D0A215' },
- { token: 'type.identifier', foreground: 'D0A215' },
- { token: 'identifier', foreground: 'CECDC3' },
- ],
- colors: {
-
- 'editor.background': '#100F0F',
- 'editor.foreground': '#CECDC3',
- 'editor.lineHighlightBackground': '#1C1B1A',
- 'editor.selectionBackground': '#CECDC333',
- 'editor.selectionHighlightBackground': '#CECDC333',
- 'editor.inactiveSelectionBackground': '#282726',
- 'editor.findMatchBackground': '#AD8301',
- 'editor.findMatchHighlightBackground': '#AD8301cc',
- 'editor.hoverHighlightBackground': '#343331',
- 'editor.rangeHighlightBackground': '#403E3C',
- 'editorCursor.foreground': '#CECDC3',
-
- 'editorLineNumber.foreground': '#403E3C',
- 'editorLineNumber.activeForeground': '#CECDC3',
-
- 'editorGutter.background': '#100F0F',
- 'editorGutter.modifiedBackground': '#3AA99F',
- 'editorGutter.addedBackground': '#879A39',
- 'editorGutter.deletedBackground': '#D14D41',
-
- 'diffEditor.insertedTextBackground': '#66800B25',
- 'diffEditor.removedTextBackground': '#AF302925',
- 'diffEditor.insertedLineBackground': '#66800B15',
- 'diffEditor.removedLineBackground': '#AF302915',
- 'diffEditor.insertedTextBorder': '#00000000',
- 'diffEditor.removedTextBorder': '#00000000',
-
- 'editorBracketMatch.background': '#282726',
- 'editorBracketMatch.border': '#343331',
-
- 'editorWhitespace.foreground': '#403E3C',
- 'editorIndentGuide.background1': '#343331',
- 'editorIndentGuide.activeBackground1': '#575653',
-
- 'editorWidget.background': '#1C1B1A',
- 'editorWidget.border': '#343331',
- 'editorSuggestWidget.background': '#100F0F',
- 'editorSuggestWidget.border': '#343331',
- 'editorSuggestWidget.foreground': '#CECDC3',
- 'editorSuggestWidget.selectedBackground': '#343331',
- 'editorHoverWidget.background': '#282726',
- 'editorHoverWidget.border': '#343331',
-
- 'editorInlayHint.foreground': '#878580',
- 'editorInlayHint.background': '#343331',
-
- 'editorError.foreground': '#D14D41',
- 'editorWarning.foreground': '#DA702C',
- 'editorInfo.foreground': '#4385BE',
-
- 'input.background': '#1C1B1A',
- 'input.foreground': '#CECDC3',
- 'input.border': '#343331',
- 'input.placeholderForeground': '#878580',
-
- 'dropdown.background': '#1C1B1A',
- 'dropdown.foreground': '#CECDC3',
- 'dropdown.border': '#343331',
- 'dropdown.listBackground': '#100F0F',
-
- 'focusBorder': '#343331',
-
- 'scrollbarSlider.background': '#34333180',
- 'scrollbarSlider.hoverBackground': '#403E3C',
- 'scrollbarSlider.activeBackground': '#575653',
- },
-};
-
-const flexokiLightTheme: MonacoThemeData = {
- base: 'vs',
- inherit: true,
- rules: [
-
- { token: '', foreground: '100F0F' },
- { token: 'source', foreground: '100F0F' },
-
- { token: 'comment', foreground: '6F6E69' },
- { token: 'comment.block', foreground: '6F6E69' },
- { token: 'comment.line', foreground: '6F6E69' },
- { token: 'comment.block.documentation', foreground: 'B7B5AC' },
-
- { token: 'string', foreground: '24837B' },
- { token: 'string.quoted', foreground: '24837B' },
- { token: 'string.template', foreground: '24837B' },
- { token: 'string.regexp', foreground: '24837B' },
-
- { token: 'string.escape', foreground: '100F0F' },
- { token: 'constant.character.escape', foreground: '100F0F' },
-
- { token: 'number', foreground: '5E409D' },
- { token: 'number.hex', foreground: '5E409D' },
- { token: 'number.float', foreground: '5E409D' },
- { token: 'constant.numeric', foreground: '5E409D' },
-
- { token: 'constant.language', foreground: 'AD8301' },
- { token: 'constant.language.boolean', foreground: 'AD8301' },
- { token: 'constant.language.null', foreground: 'AD8301' },
-
- { token: 'keyword', foreground: '205EA6' },
- { token: 'keyword.control', foreground: '205EA6' },
- { token: 'keyword.other', foreground: '205EA6' },
-
- { token: 'keyword.control.import', foreground: 'AF3029' },
- { token: 'keyword.control.from', foreground: 'AF3029' },
- { token: 'keyword.control.export', foreground: 'AF3029' },
-
- { token: 'keyword.control.exception', foreground: 'A02F6F' },
- { token: 'keyword.control.trycatch', foreground: 'A02F6F' },
-
- { token: 'keyword.operator', foreground: 'AF3029' },
- { token: 'operator', foreground: 'AF3029' },
-
- { token: 'storage', foreground: '205EA6' },
- { token: 'storage.type', foreground: '205EA6' },
- { token: 'storage.modifier', foreground: '205EA6' },
-
- { token: 'entity.name.function', foreground: 'BC5215', fontStyle: 'bold' },
- { token: 'support.function', foreground: 'BC5215', fontStyle: 'bold' },
- { token: 'meta.function-call', foreground: 'BC5215' },
-
- { token: 'entity.name.function.method', foreground: '66800B' },
-
- { token: 'entity.name.class', foreground: 'BC5215' },
- { token: 'entity.name.type.class', foreground: 'BC5215' },
- { token: 'support.class', foreground: 'BC5215' },
-
- { token: 'entity.name.type', foreground: 'AD8301' },
- { token: 'entity.name.type.interface', foreground: 'AD8301' },
- { token: 'support.type', foreground: 'AD8301' },
-
- { token: 'entity.name.type.struct', foreground: 'BC5215' },
- { token: 'entity.name.type.enum', foreground: 'BC5215' },
-
- { token: 'entity.name.type.parameter', foreground: 'BC5215' },
-
- { token: 'variable', foreground: '100F0F' },
- { token: 'variable.other', foreground: '100F0F' },
- { token: 'variable.parameter', foreground: '100F0F' },
-
- { token: 'variable.other.object', foreground: '66800B' },
- { token: 'variable.other.readwrite.alias', foreground: '66800B' },
-
- { token: 'variable.language', foreground: 'A02F6F' },
- { token: 'variable.language.this', foreground: 'A02F6F' },
- { token: 'variable.language.super', foreground: 'A02F6F' },
-
- { token: 'variable.other.property', foreground: '205EA6' },
- { token: 'support.variable.property', foreground: '205EA6' },
-
- { token: 'variable.other.constant', foreground: '100F0F' },
-
- { token: 'meta.object-literal.key', foreground: 'BC5215' },
- { token: 'support.type.property-name', foreground: 'BC5215' },
-
- { token: 'entity.name.tag', foreground: '205EA6' },
- { token: 'tag', foreground: '205EA6' },
-
- { token: 'support.class.component', foreground: 'A02F6F' },
-
- { token: 'entity.other.attribute-name', foreground: 'AD8301' },
-
- { token: 'entity.name.namespace', foreground: 'AD8301' },
-
- { token: 'entity.name.module', foreground: 'AF3029' },
-
- { token: 'meta.decorator', foreground: 'AD8301' },
- { token: 'entity.name.function.decorator', foreground: 'AD8301' },
-
- { token: 'entity.name.label', foreground: 'A02F6F' },
-
- { token: 'meta.preprocessor', foreground: 'A02F6F' },
- { token: 'entity.name.function.preprocessor', foreground: '205EA6' },
-
- { token: 'punctuation', foreground: '6F6E69' },
- { token: 'delimiter', foreground: '6F6E69' },
- { token: 'delimiter.bracket', foreground: '6F6E69' },
-
- { token: 'markup.heading', foreground: 'AD8301' },
- { token: 'markup.bold', foreground: 'AD8301', fontStyle: 'bold' },
- { token: 'markup.italic', foreground: '24837B', fontStyle: 'italic' },
- { token: 'markup.underline.link', foreground: '205EA6' },
- { token: 'markup.inline.raw', foreground: '24837B' },
-
- { token: 'invalid', foreground: 'AF3029' },
- { token: 'invalid.illegal', foreground: 'AF3029' },
-
- { token: 'string.key.json', foreground: 'BC5215' },
- { token: 'string.value.json', foreground: '24837B' },
-
- { token: 'support.type.property-name.css', foreground: '100F0F' },
- { token: 'support.constant.property-value.css', foreground: '24837B' },
-
- { token: 'type', foreground: 'AD8301' },
- { token: 'type.identifier', foreground: 'AD8301' },
- { token: 'identifier', foreground: '100F0F' },
- ],
- colors: {
-
- 'editor.background': '#FFFCF0',
- 'editor.foreground': '#100F0F',
- 'editor.lineHighlightBackground': '#F2F0E5',
- 'editor.selectionBackground': '#100F0F44',
- 'editor.selectionHighlightBackground': '#100F0F44',
- 'editor.inactiveSelectionBackground': '#E6E4D9',
- 'editor.findMatchBackground': '#D0A215',
- 'editor.findMatchHighlightBackground': '#D0A215cc',
- 'editor.hoverHighlightBackground': '#DAD8CE',
- 'editor.rangeHighlightBackground': '#CECDC3',
- 'editorCursor.foreground': '#100F0F',
-
- 'editorLineNumber.foreground': '#CECDC3',
- 'editorLineNumber.activeForeground': '#100F0F',
-
- 'editorGutter.background': '#FFFCF0',
- 'editorGutter.modifiedBackground': '#24837B',
- 'editorGutter.addedBackground': '#66800B',
- 'editorGutter.deletedBackground': '#AF3029',
-
- 'diffEditor.insertedTextBackground': '#66800B25',
- 'diffEditor.removedTextBackground': '#AF302925',
- 'diffEditor.insertedLineBackground': '#66800B15',
- 'diffEditor.removedLineBackground': '#AF302915',
- 'diffEditor.insertedTextBorder': '#00000000',
- 'diffEditor.removedTextBorder': '#00000000',
-
- 'editorBracketMatch.background': '#E6E4D9',
- 'editorBracketMatch.border': '#DAD8CE',
-
- 'editorWhitespace.foreground': '#CECDC3',
- 'editorIndentGuide.background1': '#DAD8CE',
- 'editorIndentGuide.activeBackground1': '#B7B5AC',
-
- 'editorWidget.background': '#F2F0E5',
- 'editorWidget.border': '#DAD8CE',
- 'editorSuggestWidget.background': '#FFFCF0',
- 'editorSuggestWidget.border': '#DAD8CE',
- 'editorSuggestWidget.foreground': '#100F0F',
- 'editorSuggestWidget.selectedBackground': '#DAD8CE',
- 'editorHoverWidget.background': '#E6E4D9',
- 'editorHoverWidget.border': '#DAD8CE',
-
- 'editorInlayHint.foreground': '#6F6E69',
- 'editorInlayHint.background': '#DAD8CE',
-
- 'editorError.foreground': '#AF3029',
- 'editorWarning.foreground': '#BC5215',
- 'editorInfo.foreground': '#205EA6',
-
- 'input.background': '#F2F0E5',
- 'input.foreground': '#100F0F',
- 'input.border': '#DAD8CE',
- 'input.placeholderForeground': '#6F6E69',
-
- 'dropdown.background': '#F2F0E5',
- 'dropdown.foreground': '#100F0F',
- 'dropdown.border': '#DAD8CE',
- 'dropdown.listBackground': '#FFFCF0',
-
- 'focusBorder': '#DAD8CE',
-
- 'scrollbarSlider.background': '#DAD8CE80',
- 'scrollbarSlider.hoverBackground': '#CECDC3',
- 'scrollbarSlider.activeBackground': '#B7B5AC',
- },
-};
-
-export const getMonacoThemeIdForTheme = (theme: Theme): string => {
- return theme.metadata.variant === 'dark' ? MONACO_DARK_THEME_ID : MONACO_LIGHT_THEME_ID;
-};
-
-export const ensureMonacoThemeRegistered = (theme: Theme, monacoOverride?: Monaco): void => {
- const monaco = monacoOverride ?? getMonacoFromGlobal();
- if (!monaco) return;
-
- const themeId = getMonacoThemeIdForTheme(theme);
- if (lastRegisteredThemeId === themeId) return;
-
- const themeData = theme.metadata.variant === 'dark' ? flexokiDarkTheme : flexokiLightTheme;
- monaco.editor.defineTheme(themeId, themeData);
-
- lastRegisteredThemeId = themeId;
-};
diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts
index b985c3d7..524145d6 100644
--- a/packages/ui/src/stores/useUIStore.ts
+++ b/packages/ui/src/stores/useUIStore.ts
@@ -34,6 +34,7 @@ interface UIStore {
diffLayoutPreference: 'dynamic' | 'inline' | 'side-by-side';
diffFileLayout: Record;
+ diffWrapLines: boolean;
setTheme: (theme: 'light' | 'dark' | 'system') => void;
toggleSidebar: () => void;
@@ -58,6 +59,7 @@ interface UIStore {
updateProportionalSidebarWidths: () => void;
setDiffLayoutPreference: (mode: 'dynamic' | 'inline' | 'side-by-side') => void;
setDiffFileLayout: (filePath: string, mode: 'inline' | 'side-by-side') => void;
+ setDiffWrapLines: (wrap: boolean) => void;
}
export const useUIStore = create()(
@@ -83,6 +85,7 @@ export const useUIStore = create()(
showReasoningTraces: false,
diffLayoutPreference: 'dynamic',
diffFileLayout: {},
+ diffWrapLines: false,
setTheme: (theme) => {
set({ theme });
@@ -204,6 +207,10 @@ export const useUIStore = create()(
}));
},
+ setDiffWrapLines: (wrap) => {
+ set({ diffWrapLines: wrap });
+ },
+
updateProportionalSidebarWidths: () => {
if (typeof window === 'undefined') {
return;
@@ -248,6 +255,7 @@ export const useUIStore = create()(
isSettingsDialogOpen: state.isSettingsDialogOpen,
showReasoningTraces: state.showReasoningTraces,
diffLayoutPreference: state.diffLayoutPreference,
+ diffWrapLines: state.diffWrapLines,
})
}
),
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ec76d41a..faf3afce 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -259,12 +259,12 @@ importers:
'@ibm/plex':
specifier: ^6.4.1
version: 6.4.1
- '@monaco-editor/react':
- specifier: ^4.6.0
- version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@opencode-ai/sdk':
specifier: ^1.0.65
version: 1.0.65
+ '@pierre/diffs':
+ specifier: ^1.0.0
+ version: 1.0.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-collapsible':
specifier: ^1.1.12
version: 1.1.12(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
@@ -1348,16 +1348,6 @@ packages:
'@mermaid-js/parser@0.6.3':
resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==}
- '@monaco-editor/loader@1.7.0':
- resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==}
-
- '@monaco-editor/react@4.7.0':
- resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==}
- peerDependencies:
- monaco-editor: '>= 0.25.0 < 1'
- react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
- react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
-
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -1379,6 +1369,12 @@ packages:
'@opencode-ai/sdk@1.0.65':
resolution: {integrity: sha512-35aOmXhRHNPlx0ThhYwudfZhP1Sg8y94Wsqm+XsgGfHATllQvQjNVOVPfSp3TW3xCAoJ0/PXJlcYjPS3kZDeNA==}
+ '@pierre/diffs@1.0.0':
+ resolution: {integrity: sha512-aF/hbdGI46HMTOMNYhEyXpiyb1J0Ldf2o2dBa1fYxI688t7SfCfzvBIlJm74O6+GuMg20EWhhQM+Nky7LuwLIg==}
+ peerDependencies:
+ react: ^18.3.1 || ^19.0.0
+ react-dom: ^18.3.1 || ^19.0.0
+
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -2008,6 +2004,9 @@ packages:
'@shikijs/themes@3.19.0':
resolution: {integrity: sha512-H36qw+oh91Y0s6OlFfdSuQ0Ld+5CgB/VE6gNPK+Hk4VRbVG/XQgkjnt4KzfnnoO6tZPtKJKHPjwebOCfjd6F8A==}
+ '@shikijs/transformers@3.19.0':
+ resolution: {integrity: sha512-e6vwrsyw+wx4OkcrDbL+FVCxwx8jgKiCoXzakVur++mIWVcgpzIi8vxf4/b4dVTYrV/nUx5RjinMf4tq8YV8Fw==}
+
'@shikijs/types@3.19.0':
resolution: {integrity: sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ==}
@@ -3288,6 +3287,10 @@ packages:
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
+ diff@8.0.2:
+ resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==}
+ engines: {node: '>=0.3.1'}
+
dir-compare@3.3.0:
resolution: {integrity: sha512-J7/et3WlGUCxjdnD3HAAzQ6nsnc0WL6DD7WcwJb7c39iH1+AWfg+9OqzJNaI6PkBwBvm1mhZNL9iY/nRiZXlPg==}
@@ -4355,6 +4358,9 @@ packages:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
+ lru_map@0.4.1:
+ resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==}
+
lucide-react@0.542.0:
resolution: {integrity: sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==}
peerDependencies:
@@ -4370,11 +4376,6 @@ packages:
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
- marked@14.0.0:
- resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==}
- engines: {node: '>= 18'}
- hasBin: true
-
marked@16.4.2:
resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==}
engines: {node: '>= 20'}
@@ -4666,9 +4667,6 @@ packages:
mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
- monaco-editor@0.55.1:
- resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==}
-
motion-dom@12.23.23:
resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==}
@@ -5389,9 +5387,6 @@ packages:
resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==}
engines: {node: '>= 6'}
- state-local@1.0.7:
- resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
-
statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
@@ -6658,17 +6653,6 @@ snapshots:
dependencies:
langium: 3.3.1
- '@monaco-editor/loader@1.7.0':
- dependencies:
- state-local: 1.0.7
-
- '@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
- dependencies:
- '@monaco-editor/loader': 1.7.0
- monaco-editor: 0.55.1
- react: 19.1.1
- react-dom: 19.1.1(react@19.1.1)
-
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -6687,6 +6671,18 @@ snapshots:
'@opencode-ai/sdk@1.0.65': {}
+ '@pierre/diffs@1.0.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ dependencies:
+ '@shikijs/core': 3.19.0
+ '@shikijs/engine-javascript': 3.19.0
+ '@shikijs/transformers': 3.19.0
+ diff: 8.0.2
+ hast-util-to-html: 9.0.5
+ lru_map: 0.4.1
+ react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
+ shiki: 3.19.0
+
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -7344,6 +7340,11 @@ snapshots:
dependencies:
'@shikijs/types': 3.19.0
+ '@shikijs/transformers@3.19.0':
+ dependencies:
+ '@shikijs/core': 3.19.0
+ '@shikijs/types': 3.19.0
+
'@shikijs/types@3.19.0':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
@@ -8803,6 +8804,8 @@ snapshots:
dependencies:
dequal: 2.0.3
+ diff@8.0.2: {}
+
dir-compare@3.3.0:
dependencies:
buffer-equal: 1.0.1
@@ -10074,6 +10077,8 @@ snapshots:
dependencies:
yallist: 4.0.0
+ lru_map@0.4.1: {}
+
lucide-react@0.542.0(react@19.1.1):
dependencies:
react: 19.1.1
@@ -10093,8 +10098,6 @@ snapshots:
markdown-table@3.0.4: {}
- marked@14.0.0: {}
-
marked@16.4.2: {}
matcher@3.0.0:
@@ -10609,11 +10612,6 @@ snapshots:
pkg-types: 1.3.1
ufo: 1.6.1
- monaco-editor@0.55.1:
- dependencies:
- dompurify: 3.2.7
- marked: 14.0.0
-
motion-dom@12.23.23:
dependencies:
motion-utils: 12.23.6
@@ -11468,8 +11466,6 @@ snapshots:
stat-mode@1.0.0: {}
- state-local@1.0.7: {}
-
statuses@2.0.1: {}
statuses@2.0.2: {}