feat: add file tabs and improve terminal rendering with WebGL

This commit is contained in:
Mohamed Boudra
2026-03-02 20:02:54 +07:00
parent 92b5f1e963
commit f418f3ab02
25 changed files with 1214 additions and 696 deletions
+16 -2
View File
@@ -6434,6 +6434,7 @@
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
@@ -7210,6 +7211,18 @@
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/addon-unicode11": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz",
"integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==",
"license": "MIT"
},
"node_modules/@xterm/addon-webgl": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
"license": "MIT"
},
"node_modules/@xterm/headless": {
"version": "6.0.0",
"license": "MIT",
@@ -21586,6 +21599,8 @@
"@tanstack/react-query": "^5.90.11",
"@tauri-apps/api": "^2.9.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"base64-js": "^1.5.1",
"buffer": "^6.0.3",
@@ -21726,8 +21741,7 @@
"name": "@getpaseo/desktop",
"version": "0.1.16",
"devDependencies": {
"@tauri-apps/cli": "^2.9.6",
"@tauri-apps/cli-linux-x64-gnu": "^2.9.6"
"@tauri-apps/cli": "^2.9.6"
}
},
"packages/relay": {
+2
View File
@@ -52,6 +52,8 @@
"@tanstack/react-query": "^5.90.11",
"@tauri-apps/api": "^2.9.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"base64-js": "^1.5.1",
"buffer": "^6.0.3",
@@ -0,0 +1,23 @@
import { useLocalSearchParams } from "expo-router";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { decodeFilePathFromPathSegment } from "@/utils/host-routes";
export default function HostWorkspaceFileRoute() {
const params = useLocalSearchParams<{
serverId?: string;
workspaceId?: string;
fileId?: string;
}>();
const fileId = typeof params.fileId === "string" ? params.fileId : "";
const filePath = fileId ? decodeFilePathFromPathSegment(fileId) ?? "" : "";
return (
<WorkspaceScreen
serverId={typeof params.serverId === "string" ? params.serverId : ""}
workspaceId={typeof params.workspaceId === "string" ? params.workspaceId : ""}
routeTab={filePath ? { kind: "file", path: filePath } : null}
/>
);
}
@@ -29,6 +29,7 @@ import Markdown from "react-native-markdown-display";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import Animated, {
FadeIn,
FadeOut,
@@ -81,6 +82,7 @@ import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { buildHostWorkspaceFileRoute } from "@/utils/host-routes";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) =>
@@ -129,6 +131,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
const scrollViewRef = useRef<ScrollView>(null);
const bottomAnchorRef = useRef<View>(null);
const { theme } = useUnistyles();
const router = useRouter();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const streamRenderStrategy = useMemo(
@@ -174,13 +177,12 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
);
const workspaceRoot = agent.cwd?.trim() || "";
const workspaceId = agent.projectPlacement?.checkout?.cwd ?? workspaceRoot;
const { requestDirectoryListing, requestFilePreview, selectExplorerEntry } =
useFileExplorerActions({
serverId: resolvedServerId,
workspaceId,
workspaceRoot,
});
const workspaceId = agent.projectPlacement?.checkout?.cwd?.trim() || workspaceRoot;
const { requestDirectoryListing } = useFileExplorerActions({
serverId: resolvedServerId,
workspaceId,
workspaceRoot,
});
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
// tracked in react-native-reanimated#8422.
const shouldDisableEntryExitAnimations = Platform.OS === "android";
@@ -210,14 +212,20 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
return;
}
if (normalized.file) {
const route = buildHostWorkspaceFileRoute(
resolvedServerId,
workspaceId,
normalized.file
);
router.replace(route as any);
return;
}
void requestDirectoryListing(normalized.directory, {
recordHistory: false,
setCurrentPath: false,
});
if (normalized.file) {
selectExplorerEntry(normalized.file);
void requestFilePreview(normalized.file);
}
setExplorerTabForCheckout({
serverId: resolvedServerId,
@@ -229,11 +237,12 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
},
[
agent.cwd,
requestDirectoryListing,
requestFilePreview,
selectExplorerEntry,
setExplorerTabForCheckout,
openFileExplorer,
requestDirectoryListing,
resolvedServerId,
router,
setExplorerTabForCheckout,
workspaceId,
]
);
@@ -74,7 +74,13 @@ function SortableItem<T>({
// Trigger drag - handled by dnd-kit's listeners
};
const baseTransform = CSS.Transform.toString(transform);
// dnd-kit can set `scaleX/scaleY` on the active item when dragging over a
// differently-sized droppable. For variable-height rows this can look like
// the "ghost" stretches. Keep the dragged item's size stable by zeroing
// out the dnd-kit scaling component.
const baseTransform = CSS.Transform.toString(
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform
);
const scaleTransform = isDragging ? "scale(1.02)" : "";
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
@@ -37,6 +37,7 @@ interface ExplorerSidebarProps {
workspaceId?: string | null;
workspaceRoot: string;
isGit: boolean;
onOpenFile?: (filePath: string) => void;
}
export function ExplorerSidebar({
@@ -44,6 +45,7 @@ export function ExplorerSidebar({
workspaceId,
workspaceRoot,
isGit,
onOpenFile,
}: ExplorerSidebarProps) {
const { theme } = useUnistyles();
const isScreenFocused = useIsFocused();
@@ -289,6 +291,7 @@ export function ExplorerSidebar({
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile={isMobile}
onOpenFile={onOpenFile}
/>
</Animated.View>
</GestureDetector>
@@ -324,6 +327,7 @@ export function ExplorerSidebar({
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile={false}
onOpenFile={onOpenFile}
/>
</Animated.View>
);
@@ -338,6 +342,7 @@ interface SidebarContentProps {
workspaceRoot: string;
isGit: boolean;
isMobile: boolean;
onOpenFile?: (filePath: string) => void;
}
function SidebarContent({
@@ -349,6 +354,7 @@ function SidebarContent({
workspaceRoot,
isGit,
isMobile,
onOpenFile,
}: SidebarContentProps) {
const { theme } = useUnistyles();
const resolvedTab: ExplorerTab =
@@ -413,6 +419,7 @@ function SidebarContent({
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
onOpenFile={onOpenFile}
/>
)}
</View>
@@ -3,18 +3,16 @@ import { useQuery } from "@tanstack/react-query";
import {
ActivityIndicator,
FlatList,
Image as RNImage,
ListRenderItemInfo,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
Pressable,
ScrollView as RNScrollView,
Text,
View,
Platform,
} from "react-native";
import { ScrollView, Gesture, GestureDetector } from "react-native-gesture-handler";
import { Gesture } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import Animated, {
cancelAnimation,
@@ -27,11 +25,6 @@ import Animated, {
} from "react-native-reanimated";
import { Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import {
BottomSheetModal,
BottomSheetScrollView,
BottomSheetBackdrop,
} from "@gorhom/bottom-sheet";
import {
Copy,
Download,
@@ -47,7 +40,6 @@ import {
import type {
AgentFileExplorerState,
ExplorerEntry,
ExplorerFile,
} from "@/stores/session-store";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useSessionStore } from "@/stores/session-store";
@@ -63,7 +55,6 @@ import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import {
usePanelStore,
DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
type SortOption,
} from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
@@ -80,10 +71,21 @@ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
const INDENT_PER_LEVEL = 12;
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
return `${size} B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
interface FileExplorerPaneProps {
serverId: string;
workspaceId?: string | null;
workspaceRoot: string;
onOpenFile?: (filePath: string) => void;
}
interface TreeRow {
@@ -95,6 +97,7 @@ export function FileExplorerPane({
serverId,
workspaceId,
workspaceRoot,
onOpenFile,
}: FileExplorerPaneProps) {
const { theme } = useUnistyles();
const isMobile =
@@ -132,7 +135,6 @@ export function FileExplorerPane({
const {
workspaceStateKey: actionsWorkspaceStateKey,
requestDirectoryListing,
requestFilePreview,
requestFileDownloadToken,
selectExplorerEntry,
} = useFileExplorerActions({
@@ -142,23 +144,13 @@ export function FileExplorerPane({
});
const sortOption = usePanelStore((state) => state.explorerSortOption);
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
const splitRatio = usePanelStore((state) => state.explorerFilesSplitRatio);
const setSplitRatio = usePanelStore((state) => state.setExplorerFilesSplitRatio);
const directories = explorerState?.directories ?? new Map();
const files = explorerState?.files ?? new Map();
const pendingRequest = explorerState?.pendingRequest ?? null;
const isExplorerLoading = explorerState?.isLoading ?? false;
const error = explorerState?.lastError ?? null;
const selectedEntryPath = explorerState?.selectedEntryPath ?? null;
const preview = selectedEntryPath ? files.get(selectedEntryPath) : null;
const isPreviewLoading = Boolean(
isExplorerLoading &&
pendingRequest?.mode === "file" &&
pendingRequest?.path === selectedEntryPath
);
const isDirectoryLoading = useCallback(
(path: string) =>
Boolean(
@@ -168,15 +160,9 @@ export function FileExplorerPane({
);
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
const [containerWidth, setContainerWidth] = useState(0);
const wasInlinePreviewVisibleRef = useRef(false);
const treeListRef = useRef<FlatList<TreeRow>>(null);
const treeScrollbarMetrics = useWebDesktopScrollbarMetrics();
// Bottom sheet for file preview (mobile)
const previewSheetRef = useRef<BottomSheetModal>(null);
const previewSnapPoints = useMemo(() => ["70%", "95%"], []);
const hasInitializedRef = useRef(false);
useEffect(() => {
@@ -222,22 +208,6 @@ export function FileExplorerPane({
});
}, [directories, hasWorkspaceScope, requestDirectoryListing, selectedEntryPath]);
// Open/close preview sheet based on selection
useEffect(() => {
if (!isMobile) {
return;
}
if (selectedEntryPath) {
previewSheetRef.current?.present();
} else {
previewSheetRef.current?.dismiss();
}
}, [isMobile, selectedEntryPath]);
const handleClosePreview = useCallback(() => {
selectExplorerEntry(null);
}, [selectExplorerEntry]);
const handleToggleDirectory = useCallback(
(entry: ExplorerEntry) => {
if (!hasWorkspaceScope) {
@@ -272,9 +242,9 @@ export function FileExplorerPane({
return;
}
selectExplorerEntry(entry.path);
void requestFilePreview(entry.path);
onOpenFile?.(entry.path);
},
[hasWorkspaceScope, requestFilePreview, selectExplorerEntry]
[hasWorkspaceScope, onOpenFile, selectExplorerEntry]
);
const handleEntryPress = useCallback(
@@ -342,7 +312,6 @@ export function FileExplorerPane({
setCurrentPath: false,
})
),
...(selectedEntryPath ? [requestFilePreview(selectedEntryPath)] : []),
]);
return null;
},
@@ -420,93 +389,6 @@ export function FileExplorerPane({
[explorerState]
);
const shouldShowInlinePreview = !isMobile && Boolean(selectedEntryPath);
const minTreeWidth = 220;
const minPreviewWidth = 320;
const safeSplitRatio = Number.isFinite(splitRatio)
? splitRatio
: DEFAULT_EXPLORER_FILES_SPLIT_RATIO;
const splitAvailableWidth = useSharedValue(0);
const splitMaxTreeWidth = useSharedValue(minTreeWidth);
const splitTreeWidth = useSharedValue(minTreeWidth);
const splitStartTreeWidth = useSharedValue(minTreeWidth);
useEffect(() => {
if (!shouldShowInlinePreview) {
wasInlinePreviewVisibleRef.current = false;
return;
}
if (containerWidth <= 0) {
return;
}
const available = Math.max(0, containerWidth);
const maxTree = Math.max(minTreeWidth, available - minPreviewWidth);
const isOpeningInlinePreview = !wasInlinePreviewVisibleRef.current;
const desired = isOpeningInlinePreview
? Math.round(available * safeSplitRatio)
: splitTreeWidth.value;
const clamped = Math.max(minTreeWidth, Math.min(maxTree, desired));
splitAvailableWidth.value = available;
splitMaxTreeWidth.value = maxTree;
splitTreeWidth.value = clamped;
wasInlinePreviewVisibleRef.current = true;
}, [
containerWidth,
minPreviewWidth,
minTreeWidth,
safeSplitRatio,
shouldShowInlinePreview,
splitAvailableWidth,
splitMaxTreeWidth,
splitTreeWidth,
]);
const treePaneAnimatedStyle = useAnimatedStyle(() => ({
width: splitTreeWidth.value,
flexBasis: splitTreeWidth.value,
flexGrow: 0,
flexShrink: 0,
}));
const splitResizeGesture = useMemo(() => {
if (isMobile || !shouldShowInlinePreview) {
return Gesture.Pan().enabled(false);
}
return Gesture.Pan()
.hitSlop({ left: 12, right: 12, top: 0, bottom: 0 })
.onStart(() => {
splitStartTreeWidth.value = splitTreeWidth.value;
})
.onUpdate((event) => {
const nextWidth = splitStartTreeWidth.value - event.translationX;
const clamped = Math.max(
minTreeWidth,
Math.min(splitMaxTreeWidth.value, nextWidth)
);
splitTreeWidth.value = clamped;
})
.onEnd(() => {
const available = splitAvailableWidth.value;
const ratio = available > 0 ? splitTreeWidth.value / available : safeSplitRatio;
runOnJS(setSplitRatio)(ratio);
});
}, [
isMobile,
minTreeWidth,
safeSplitRatio,
setSplitRatio,
shouldShowInlinePreview,
splitAvailableWidth,
splitMaxTreeWidth,
splitStartTreeWidth,
splitTreeWidth,
]);
const renderTreeRow = useCallback(
({ item }: ListRenderItemInfo<TreeRow>) => {
const entry = item.entry;
@@ -606,27 +488,6 @@ export function FileExplorerPane({
]
);
const handlePreviewSheetChange = useCallback(
(index: number) => {
if (index === -1) {
handleClosePreview();
}
},
[handleClosePreview]
);
const renderPreviewBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.5}
/>
),
[]
);
const handleBackFromError = useCallback(() => {
if (!hasWorkspaceScope) {
return;
@@ -667,7 +528,6 @@ export function FileExplorerPane({
return (
<View
style={styles.container}
onLayout={(event) => setContainerWidth(event.nativeEvent.layout.width)}
>
{error ? (
<View style={styles.centerState}>
@@ -701,385 +561,66 @@ export function FileExplorerPane({
<Text style={styles.emptyText}>No files</Text>
</View>
) : (
<View style={styles.desktopSplit}>
{shouldShowInlinePreview ? (
<View style={styles.previewPane}>
<View style={styles.paneHeader} testID="preview-pane-header">
<Text style={styles.previewHeaderText} numberOfLines={1}>
{selectedEntryPath?.split("/").pop() ?? "Preview"}
</Text>
<View style={styles.previewHeaderRight}>
{isPreviewLoading ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : null}
<Pressable
onPress={handleClosePreview}
hitSlop={8}
style={({ hovered, pressed }) => [
styles.iconButton,
(hovered || pressed) && styles.iconButtonHovered,
]}
accessibilityRole="button"
accessibilityLabel="Close preview"
>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
</View>
<FilePreviewBody
preview={preview}
isLoading={isPreviewLoading}
variant="inline"
showDesktopWebScrollbar={showDesktopWebScrollbar}
/>
<View style={[styles.treePane, styles.treePaneFill]}>
<View style={styles.paneHeader} testID="files-pane-header">
<View style={styles.paneHeaderLeft} />
<View style={styles.paneHeaderRight}>
<Pressable
onPress={handleRefresh}
disabled={isRefreshFetching}
hitSlop={8}
style={({ hovered, pressed }) => [
styles.iconButton,
(hovered || pressed) && styles.iconButtonHovered,
pressed && styles.iconButtonPressed,
]}
accessibilityRole="button"
accessibilityLabel="Refresh files"
>
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
<RotateCw size={16} color={theme.colors.foregroundMuted} />
</Animated.View>
</Pressable>
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
</Pressable>
</View>
) : null}
{shouldShowInlinePreview ? (
<Animated.View
style={[
styles.treePane,
styles.treePaneWithPreview,
{ minWidth: minTreeWidth },
treePaneAnimatedStyle,
]}
>
<GestureDetector gesture={splitResizeGesture}>
<View
style={[
styles.splitResizeHandle,
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
Platform.OS === "web" && ({ touchAction: "none", userSelect: "none" } as any),
]}
/>
</GestureDetector>
<View style={styles.paneHeader} testID="files-pane-header">
<View style={styles.paneHeaderLeft} />
<View style={styles.paneHeaderRight}>
<Pressable
onPress={handleRefresh}
disabled={isRefreshFetching}
hitSlop={8}
style={({ hovered, pressed }) => [
styles.iconButton,
(hovered || pressed) && styles.iconButtonHovered,
pressed && styles.iconButtonPressed,
]}
accessibilityRole="button"
accessibilityLabel="Refresh files"
>
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
<RotateCw size={16} color={theme.colors.foregroundMuted} />
</Animated.View>
</Pressable>
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
</Pressable>
</View>
</View>
<FlatList
ref={treeListRef}
style={styles.treeList}
data={treeRows}
renderItem={renderTreeRow}
keyExtractor={(row) => row.entry.path}
testID="file-explorer-tree-scroll"
contentContainerStyle={styles.entriesContent}
onLayout={
showDesktopWebScrollbar ? handleTreeListLayout : undefined
}
onScroll={
showDesktopWebScrollbar ? handleTreeListScroll : undefined
}
onContentSizeChange={
showDesktopWebScrollbar
? treeScrollbarMetrics.onContentSizeChange
: undefined
}
scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
initialNumToRender={24}
maxToRenderPerBatch={40}
windowSize={12}
/>
<WebDesktopScrollbarOverlay
enabled={showDesktopWebScrollbar}
metrics={treeScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
treeListRef.current?.scrollToOffset({
offset: nextOffset,
animated: false,
});
}}
/>
</Animated.View>
) : (
<View style={[styles.treePane, styles.treePaneFill]}>
<View style={styles.paneHeader} testID="files-pane-header">
<View style={styles.paneHeaderLeft} />
<View style={styles.paneHeaderRight}>
<Pressable
onPress={handleRefresh}
disabled={isRefreshFetching}
hitSlop={8}
style={({ hovered, pressed }) => [
styles.iconButton,
(hovered || pressed) && styles.iconButtonHovered,
pressed && styles.iconButtonPressed,
]}
accessibilityRole="button"
accessibilityLabel="Refresh files"
>
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
<RotateCw size={16} color={theme.colors.foregroundMuted} />
</Animated.View>
</Pressable>
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
</Pressable>
</View>
</View>
<FlatList
ref={treeListRef}
style={styles.treeList}
data={treeRows}
renderItem={renderTreeRow}
keyExtractor={(row) => row.entry.path}
testID="file-explorer-tree-scroll"
contentContainerStyle={styles.entriesContent}
onLayout={
showDesktopWebScrollbar ? handleTreeListLayout : undefined
}
onScroll={
showDesktopWebScrollbar ? handleTreeListScroll : undefined
}
onContentSizeChange={
showDesktopWebScrollbar
? treeScrollbarMetrics.onContentSizeChange
: undefined
}
scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
initialNumToRender={24}
maxToRenderPerBatch={40}
windowSize={12}
/>
<WebDesktopScrollbarOverlay
enabled={showDesktopWebScrollbar}
metrics={treeScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
treeListRef.current?.scrollToOffset({
offset: nextOffset,
animated: false,
});
}}
/>
</View>
)}
</View>
<FlatList
ref={treeListRef}
style={styles.treeList}
data={treeRows}
renderItem={renderTreeRow}
keyExtractor={(row) => row.entry.path}
testID="file-explorer-tree-scroll"
contentContainerStyle={styles.entriesContent}
onLayout={showDesktopWebScrollbar ? handleTreeListLayout : undefined}
onScroll={showDesktopWebScrollbar ? handleTreeListScroll : undefined}
onContentSizeChange={
showDesktopWebScrollbar ? treeScrollbarMetrics.onContentSizeChange : undefined
}
scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
initialNumToRender={24}
maxToRenderPerBatch={40}
windowSize={12}
/>
<WebDesktopScrollbarOverlay
enabled={showDesktopWebScrollbar}
metrics={treeScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
treeListRef.current?.scrollToOffset({
offset: nextOffset,
animated: false,
});
}}
/>
</View>
)}
{isMobile ? (
<BottomSheetModal
ref={previewSheetRef}
snapPoints={previewSnapPoints}
index={0}
enableDynamicSizing={false}
onChange={handlePreviewSheetChange}
backdropComponent={renderPreviewBackdrop}
enablePanDownToClose
backgroundStyle={styles.sheetBackground}
handleIndicatorStyle={styles.handleIndicator}
>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle} numberOfLines={1}>
{selectedEntryPath?.split("/").pop() ?? "Preview"}
</Text>
<Pressable onPress={handleClosePreview} style={styles.sheetCloseButton}>
<X size={20} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
<FilePreviewBody
preview={preview}
isLoading={isPreviewLoading}
variant="sheet"
showDesktopWebScrollbar={false}
/>
</BottomSheetModal>
) : null}
</View>
);
}
function FilePreviewBody({
preview,
isLoading,
variant,
showDesktopWebScrollbar,
}: {
preview: ExplorerFile | null;
isLoading: boolean;
variant: "inline" | "sheet";
showDesktopWebScrollbar: boolean;
}) {
const enablePreviewDesktopScrollbar =
variant === "inline" && showDesktopWebScrollbar;
const previewScrollRef = useRef<RNScrollView>(null);
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics();
const handlePreviewScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (enablePreviewDesktopScrollbar) {
previewScrollbarMetrics.onScroll(event);
}
},
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
);
const handlePreviewLayout = useCallback(
(event: LayoutChangeEvent) => {
if (enablePreviewDesktopScrollbar) {
previewScrollbarMetrics.onLayout(event);
}
},
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
);
if (isLoading && !preview) {
return (
<View style={styles.sheetCenterState}>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file…</Text>
</View>
);
}
if (!preview) {
return (
<View style={styles.sheetCenterState}>
<Text style={styles.emptyText}>No preview available</Text>
</View>
);
}
if (preview.kind === "text") {
if (variant === "sheet") {
return (
<BottomSheetScrollView style={styles.previewContent}>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.previewCodeScrollContent}
>
<Text style={styles.codeText}>{preview.content}</Text>
</ScrollView>
</BottomSheetScrollView>
);
}
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
onContentSizeChange={
enablePreviewDesktopScrollbar
? previewScrollbarMetrics.onContentSizeChange
: undefined
}
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
>
<RNScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.previewCodeScrollContent}
>
<Text style={styles.codeText}>{preview.content}</Text>
</RNScrollView>
</RNScrollView>
<WebDesktopScrollbarOverlay
enabled={enablePreviewDesktopScrollbar}
metrics={previewScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}}
/>
</View>
);
}
if (preview.kind === "image" && preview.content) {
if (variant === "sheet") {
return (
<BottomSheetScrollView contentContainerStyle={styles.previewImageScrollContent}>
<RNImage
source={{
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
}}
style={styles.previewImage}
resizeMode="contain"
/>
</BottomSheetScrollView>
);
}
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
contentContainerStyle={styles.previewImageScrollContent}
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
onContentSizeChange={
enablePreviewDesktopScrollbar
? previewScrollbarMetrics.onContentSizeChange
: undefined
}
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
>
<RNImage
source={{
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
}}
style={styles.previewImage}
resizeMode="contain"
/>
</RNScrollView>
<WebDesktopScrollbarOverlay
enabled={enablePreviewDesktopScrollbar}
metrics={previewScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}}
/>
</View>
);
}
return (
<View style={styles.sheetCenterState}>
<Text style={styles.emptyText}>Binary preview unavailable</Text>
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
</View>
);
}
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
return `${size} B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
type EntryDisplayKind = "directory" | "image" | "text" | "other";
const IMAGE_EXTENSIONS = new Set([
+278
View File
@@ -0,0 +1,278 @@
import { useCallback, useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import {
ActivityIndicator,
Image as RNImage,
ScrollView as RNScrollView,
Text,
View,
Platform,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from "react-native";
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "@/components/web-desktop-scrollbar";
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
return `${size} B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
function FilePreviewBody({
preview,
isLoading,
showDesktopWebScrollbar,
}: {
preview: ExplorerFile | null;
isLoading: boolean;
showDesktopWebScrollbar: boolean;
}) {
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar;
const previewScrollRef = useRef<RNScrollView>(null);
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics();
const handlePreviewScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (enablePreviewDesktopScrollbar) {
previewScrollbarMetrics.onScroll(event);
}
},
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
);
const handlePreviewLayout = useCallback(
(event: LayoutChangeEvent) => {
if (enablePreviewDesktopScrollbar) {
previewScrollbarMetrics.onLayout(event);
}
},
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
);
if (isLoading && !preview) {
return (
<View style={styles.centerState}>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file…</Text>
</View>
);
}
if (!preview) {
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>No preview available</Text>
</View>
);
}
if (preview.kind === "text") {
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
onContentSizeChange={
enablePreviewDesktopScrollbar
? previewScrollbarMetrics.onContentSizeChange
: undefined
}
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
>
<RNScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.previewCodeScrollContent}
>
<Text style={styles.codeText}>{preview.content}</Text>
</RNScrollView>
</RNScrollView>
<WebDesktopScrollbarOverlay
enabled={enablePreviewDesktopScrollbar}
metrics={previewScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}}
/>
</View>
);
}
if (preview.kind === "image" && preview.content) {
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
contentContainerStyle={styles.previewImageScrollContent}
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
onContentSizeChange={
enablePreviewDesktopScrollbar
? previewScrollbarMetrics.onContentSizeChange
: undefined
}
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
>
<RNImage
source={{
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
}}
style={styles.previewImage}
resizeMode="contain"
/>
</RNScrollView>
<WebDesktopScrollbarOverlay
enabled={enablePreviewDesktopScrollbar}
metrics={previewScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}}
/>
</View>
);
}
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>Binary preview unavailable</Text>
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
</View>
);
}
export function FilePane({
serverId,
workspaceRoot,
filePath,
}: {
serverId: string;
workspaceRoot: string;
filePath: string;
}) {
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const normalizedFilePath = useMemo(() => trimNonEmpty(filePath), [filePath]);
const query = useQuery({
queryKey: ["workspaceFile", serverId, normalizedWorkspaceRoot, normalizedFilePath],
enabled: Boolean(client && normalizedWorkspaceRoot && normalizedFilePath),
queryFn: async () => {
if (!client || !normalizedWorkspaceRoot || !normalizedFilePath) {
return { file: null as ExplorerFile | null, error: "Host is not connected" };
}
const payload = await client.exploreFileSystem(
normalizedWorkspaceRoot,
normalizedFilePath,
"file"
);
return { file: payload.file ?? null, error: payload.error ?? null };
},
staleTime: 5_000,
});
return (
<View style={styles.container} testID="workspace-file-pane">
{query.data?.error ? (
<View style={styles.centerState}>
<Text style={styles.errorText}>{query.data.error}</Text>
</View>
) : null}
<FilePreviewBody
preview={query.data?.file ?? null}
isLoading={query.isFetching}
showDesktopWebScrollbar={showDesktopWebScrollbar}
/>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
minHeight: 0,
backgroundColor: theme.colors.surface0,
},
centerState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: theme.spacing[4],
},
loadingText: {
marginTop: theme.spacing[2],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
errorText: {
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
binaryMetaText: {
marginTop: theme.spacing[2],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
previewScrollContainer: {
flex: 1,
minHeight: 0,
},
previewContent: {
flex: 1,
minHeight: 0,
},
previewCodeScrollContent: {
padding: theme.spacing[4],
},
codeText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.45,
color: theme.colors.foreground,
},
previewImageScrollContent: {
flexGrow: 1,
padding: theme.spacing[4],
alignItems: "center",
justifyContent: "center",
},
previewImage: {
width: "100%",
height: 420,
},
}));
@@ -57,7 +57,10 @@ function SortableItem<T>({
// This is a no-op but matches the mobile API
}, []);
const baseTransform = CSS.Transform.toString(transform);
// See `draggable-list.web.tsx` for details on why we zero out dnd-kit scale.
const baseTransform = CSS.Transform.toString(
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform
);
const scaleTransform = isDragging ? "scale(1.01)" : "";
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
@@ -3,6 +3,7 @@
import { useEffect, useRef } from "react";
import type { DOMProps } from "expo/dom";
import "@xterm/xterm/css/xterm.css";
import type { ITheme } from "@xterm/xterm";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
@@ -12,10 +13,9 @@ interface TerminalEmulatorProps {
initialOutputText: string;
outputChunkText: string;
outputChunkSequence: number;
outputChunkReplay?: boolean;
testId?: string;
backgroundColor?: string;
foregroundColor?: string;
cursorColor?: string;
xtermTheme?: ITheme;
swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
@@ -44,10 +44,13 @@ export default function TerminalEmulator({
initialOutputText,
outputChunkText,
outputChunkSequence,
outputChunkReplay = false,
testId = "terminal-surface",
backgroundColor = "#0b0b0b",
foregroundColor = "#e6e6e6",
cursorColor = "#e6e6e6",
xtermTheme = {
background: "#0b0b0b",
foreground: "#e6e6e6",
cursor: "#e6e6e6",
},
swipeGesturesEnabled = false,
onSwipeLeft,
onSwipeRight,
@@ -203,11 +206,7 @@ export default function TerminalEmulator({
root,
host,
initialOutputText,
theme: {
backgroundColor,
foregroundColor,
cursorColor,
},
theme: xtermTheme,
});
appliedInitialOutputRef.current = initialOutputText;
@@ -218,7 +217,7 @@ export default function TerminalEmulator({
}
appliedInitialOutputRef.current = null;
};
}, [backgroundColor, cursorColor, foregroundColor, streamKey]);
}, [streamKey, xtermTheme]);
useEffect(() => {
const runtime = runtimeRef.current;
@@ -273,11 +272,12 @@ export default function TerminalEmulator({
}
runtime.write({
text: outputChunkText,
suppressInput: outputChunkReplay,
onCommitted: () => {
onOutputChunkConsumed?.(outputChunkSequence);
},
});
}, [onOutputChunkConsumed, outputChunkSequence, outputChunkText]);
}, [onOutputChunkConsumed, outputChunkReplay, outputChunkSequence, outputChunkText]);
useEffect(() => {
if (focusRequestToken <= 0) {
@@ -304,7 +304,7 @@ export default function TerminalEmulator({
height: "100%",
minHeight: 0,
minWidth: 0,
backgroundColor,
backgroundColor: xtermTheme.background ?? "#0b0b0b",
overflow: "hidden",
overscrollBehavior: "none",
touchAction: "pan-y",
@@ -42,6 +42,7 @@ import {
terminalDebugLog,
} from "@/terminal/runtime/terminal-debug";
import { usePanelStore } from "@/stores/panel-store";
import { toXtermTheme } from "@/utils/to-xterm-theme";
import TerminalEmulator from "./terminal-emulator";
interface TerminalPaneProps {
@@ -84,6 +85,7 @@ type ModifierState = {
type TerminalOutputChunkState = {
sequence: number;
text: string;
replay: boolean;
};
type PendingTerminalInput =
@@ -146,6 +148,7 @@ export function TerminalPane({
manageTerminalDirectorySubscription = true,
}: TerminalPaneProps) {
const { theme } = useUnistyles();
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]);
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
@@ -178,6 +181,7 @@ export function TerminalPane({
const [selectedOutputChunk, setSelectedOutputChunk] = useState<TerminalOutputChunkState>({
sequence: 0,
text: "",
replay: false,
});
const [selectedOutputSnapshot, setSelectedOutputSnapshot] = useState("");
const [activeStream, setActiveStream] = useState<{
@@ -600,8 +604,8 @@ export function TerminalPane({
const controller = new TerminalStreamController({
client,
getPreferredSize: () => lastReportedSizeRef.current,
onChunk: ({ terminalId, text }) => {
outputPump.append({ terminalId, text });
onChunk: ({ terminalId, text, replay }) => {
outputPump.append({ terminalId, text, replay });
},
onReset: ({ terminalId }) => {
outputPump.clearTerminal({ terminalId });
@@ -626,7 +630,7 @@ export function TerminalPane({
useEffect(() => {
outputDeliveryQueueRef.current?.reset();
pendingTerminalInputRef.current = [];
setSelectedOutputChunk({ sequence: 0, text: "" });
setSelectedOutputChunk({ sequence: 0, text: "", replay: false });
outputPumpRef.current?.setSelectedTerminal({
terminalId: selectedTerminalId,
});
@@ -1122,10 +1126,9 @@ export function TerminalPane({
initialOutputText={selectedOutputSnapshot}
outputChunkText={selectedOutputChunk.text}
outputChunkSequence={selectedOutputChunk.sequence}
outputChunkReplay={selectedOutputChunk.replay}
testId="terminal-surface"
backgroundColor={theme.colors.background}
foregroundColor={theme.colors.foreground}
cursorColor={theme.colors.foreground}
xtermTheme={xtermTheme}
swipeGesturesEnabled={swipeGesturesEnabled}
onSwipeRight={() => {
if (!swipeGesturesEnabled) {
@@ -14,11 +14,13 @@ import * as Clipboard from "expo-clipboard";
import {
Bot,
ChevronDown,
FileText,
Folder,
GitBranch,
MoreVertical,
PanelRight,
Plus,
SquareTerminal,
Terminal,
X,
} from "lucide-react-native";
@@ -45,6 +47,7 @@ import {
} from "@/components/ui/context-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { FilePane } from "@/components/file-pane";
import { TerminalPane } from "@/components/terminal-pane";
import { SortableInlineList } from "@/components/sortable-inline-list";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
@@ -52,6 +55,7 @@ import { useToast } from "@/contexts/toast-context";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { useWorkspaceFileTabsStore } from "@/stores/workspace-file-tabs-store";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceTabsStore,
@@ -61,7 +65,9 @@ import {
buildHostAgentDetailRoute,
buildHostWorkspaceRoute,
buildHostWorkspaceAgentRoute,
buildHostWorkspaceFileRoute,
buildHostWorkspaceTerminalRoute,
encodeFilePathForPathSegment,
decodeWorkspaceIdFromPathSegment,
} from "@/utils/host-routes";
import { buildNewAgentRoute } from "@/utils/new-agent-routing";
@@ -84,6 +90,7 @@ const DROPDOWN_WIDTH = 220;
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
const NEW_TAB_TERMINAL_OPTION_ID = "__new_tab_terminal__";
const EMPTY_TAB_ORDER: string[] = [];
const EMPTY_OPEN_FILE_PATHS: string[] = [];
type TabAvailability = "available" | "invalid" | "unknown";
@@ -110,6 +117,13 @@ type WorkspaceTabDescriptor =
terminalId: string;
label: string;
subtitle: string;
}
| {
key: string;
kind: "file";
filePath: string;
label: string;
subtitle: string;
};
function applyWorkspaceTabOrder(input: {
@@ -219,6 +233,13 @@ function normalizeWorkspaceTab(
}
return { kind: "terminal", terminalId };
}
if (value.kind === "file") {
const path = trimNonEmpty(value.path);
if (!path) {
return null;
}
return { kind: "file", path: path.replace(/\\/g, "/") };
}
return null;
}
@@ -235,6 +256,9 @@ function tabEquals(left: WorkspaceTabTarget | null, right: WorkspaceTabTarget |
if (left.kind === "terminal" && right.kind === "terminal") {
return left.terminalId === right.terminalId;
}
if (left.kind === "file" && right.kind === "file") {
return left.path === right.path;
}
return false;
}
@@ -250,6 +274,13 @@ function buildTabRoute(input: {
input.tab.agentId
);
}
if (input.tab.kind === "file") {
return buildHostWorkspaceFileRoute(
input.serverId,
input.workspaceId,
input.tab.path
);
}
return buildHostWorkspaceTerminalRoute(
input.serverId,
input.workspaceId,
@@ -270,6 +301,9 @@ function resolveTabAvailability(input: {
}
return input.agentsById.has(input.tab.agentId) ? "available" : "invalid";
}
if (input.tab.kind === "file") {
return "available";
}
if (!input.terminalsHydrated) {
return "unknown";
}
@@ -293,6 +327,9 @@ function toWorkspaceTabTarget(
if (tab.kind === "agent") {
return { kind: "agent", agentId: tab.agentId };
}
if (tab.kind === "file") {
return { kind: "file", path: tab.filePath };
}
return { kind: "terminal", terminalId: tab.terminalId };
}
@@ -566,33 +603,6 @@ function WorkspaceScreenContent({
return map;
}, [workspaceAgents]);
const baseTabs = useMemo<WorkspaceTabDescriptor[]>(() => {
const next: WorkspaceTabDescriptor[] = [];
for (const agent of workspaceAgents) {
next.push({
key: `agent:${agent.id}`,
kind: "agent",
agentId: agent.id,
provider: agent.provider,
label: agent.title?.trim() || "New agent",
subtitle: `${formatProviderLabel(agent.provider)} agent`,
});
}
for (const terminal of terminals) {
next.push({
key: `terminal:${terminal.id}`,
kind: "terminal",
terminalId: terminal.id,
label: terminal.name,
subtitle: "Terminal",
});
}
return next;
}, [terminals, workspaceAgents]);
const terminalIds = useMemo(() => {
const set = new Set<string>();
for (const terminal of terminals) {
@@ -628,6 +638,64 @@ function WorkspaceScreenContent({
);
const setTabOrder = useWorkspaceTabsStore((state) => state.setTabOrder);
const openFileTab = useWorkspaceFileTabsStore((state) => state.openFileTab);
const closeFileTab = useWorkspaceFileTabsStore((state) => state.closeFileTab);
useEffect(() => {
if (requestedTab?.kind !== "file") {
return;
}
openFileTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
filePath: requestedTab.path,
});
}, [normalizedServerId, normalizedWorkspaceId, openFileTab, requestedTab]);
const openFilePaths = useWorkspaceFileTabsStore((state) =>
persistenceKey
? state.openFilePathsByWorkspace[persistenceKey] ?? EMPTY_OPEN_FILE_PATHS
: EMPTY_OPEN_FILE_PATHS
);
const baseTabs = useMemo<WorkspaceTabDescriptor[]>(() => {
const next: WorkspaceTabDescriptor[] = [];
for (const agent of workspaceAgents) {
next.push({
key: `agent:${agent.id}`,
kind: "agent",
agentId: agent.id,
provider: agent.provider,
label: agent.title?.trim() || "New agent",
subtitle: `${formatProviderLabel(agent.provider)} agent`,
});
}
for (const terminal of terminals) {
next.push({
key: `terminal:${terminal.id}`,
kind: "terminal",
terminalId: terminal.id,
label: terminal.name,
subtitle: "Terminal",
});
}
for (const filePath of openFilePaths) {
const fileName = filePath.split("/").filter(Boolean).pop() ?? filePath;
next.push({
key: `file:${filePath}`,
kind: "file",
filePath,
label: fileName,
subtitle: filePath,
});
}
return next;
}, [openFilePaths, terminals, workspaceAgents]);
const tabs = useMemo(
() => applyWorkspaceTabOrder({ tabs: baseTabs, keys: tabOrder }),
[baseTabs, tabOrder]
@@ -648,6 +716,9 @@ function WorkspaceScreenContent({
if (first.kind === "agent") {
return { kind: "agent", agentId: first.agentId };
}
if (first.kind === "file") {
return { kind: "file", path: first.filePath };
}
return { kind: "terminal", terminalId: first.terminalId };
}, [tabs]);
@@ -737,6 +808,13 @@ function WorkspaceScreenContent({
if (tabEquals(tab, resolvedTab)) {
return;
}
if (tab.kind === "file") {
openFileTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
filePath: tab.path,
});
}
const targetRoute = buildTabRoute({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
@@ -752,12 +830,23 @@ function WorkspaceScreenContent({
[
normalizedServerId,
normalizedWorkspaceId,
openFileTab,
resolvedTab,
router,
setLastFocusedTab,
]
);
const handleOpenFileFromExplorer = useCallback(
(filePath: string) => {
if (isMobile) {
closeToAgent();
}
navigateToTab({ kind: "file", path: filePath });
},
[closeToAgent, isMobile, navigateToTab]
);
useEffect(() => {
if (!resolvedTab) {
return;
@@ -804,6 +893,7 @@ function WorkspaceScreenContent({
]);
const [isTabSwitcherOpen, setIsTabSwitcherOpen] = useState(false);
const [isNewTerminalHovered, setIsNewTerminalHovered] = useState(false);
const [hoveredTabKey, setHoveredTabKey] = useState<string | null>(null);
const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState<string | null>(
null
@@ -817,6 +907,10 @@ function WorkspaceScreenContent({
map.set(tab.key, { kind: "agent", agentId: tab.agentId });
continue;
}
if (tab.kind === "file") {
map.set(tab.key, { kind: "file", path: tab.filePath });
continue;
}
map.set(tab.key, { kind: "terminal", terminalId: tab.terminalId });
}
return map;
@@ -829,6 +923,9 @@ function WorkspaceScreenContent({
if (resolvedTab.kind === "agent") {
return `agent:${resolvedTab.agentId}`;
}
if (resolvedTab.kind === "file") {
return `file:${resolvedTab.path}`;
}
return `terminal:${resolvedTab.terminalId}`;
}, [resolvedTab]);
@@ -1036,6 +1133,47 @@ function WorkspaceScreenContent({
]
);
const handleCloseFileTab = useCallback(
(filePath: string) => {
const tabKey = `file:${filePath}`;
const nextTab = resolvedTab?.kind === "file" && resolvedTab.path === filePath
? getTabAfterClosing(tabKey)
: null;
closeFileTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
filePath,
});
setHoveredTabKey((current) => (current === tabKey ? null : current));
setHoveredCloseTabKey((current) => (current === tabKey ? null : current));
if (nextTab) {
navigateToTab(nextTab);
return;
}
if (resolvedTab?.kind === "file" && resolvedTab.path === filePath) {
router.replace(
buildHostWorkspaceRoute(
normalizedServerId,
normalizedWorkspaceId
) as any
);
}
},
[
closeFileTab,
getTabAfterClosing,
navigateToTab,
normalizedServerId,
normalizedWorkspaceId,
resolvedTab,
router,
]
);
const handleCopyAgentId = useCallback(
async (agentId: string) => {
if (!agentId) return;
@@ -1093,22 +1231,33 @@ function WorkspaceScreenContent({
const agentIds: string[] = [];
const terminalIdsToClose: string[] = [];
const filePathsToClose: string[] = [];
for (const tab of toClose) {
if (tab.kind === "agent") {
agentIds.push(tab.agentId);
} else {
} else if (tab.kind === "terminal") {
terminalIdsToClose.push(tab.terminalId);
} else {
filePathsToClose.push(tab.filePath);
}
}
const confirmed = await confirmDialog({
title: "Close tabs to the right?",
message:
agentIds.length > 0 && terminalIdsToClose.length > 0
? `This will archive ${agentIds.length} agent(s) and close ${terminalIdsToClose.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`
: terminalIdsToClose.length > 0
? `This will close ${terminalIdsToClose.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`
: `This will archive ${agentIds.length} agent(s).`,
agentIds.length > 0 && terminalIdsToClose.length > 0 && filePathsToClose.length > 0
? `This will archive ${agentIds.length} agent(s), close ${terminalIdsToClose.length} terminal(s), and close ${filePathsToClose.length} file(s). Any running process in a closed terminal will be stopped immediately.`
: agentIds.length > 0 && terminalIdsToClose.length > 0
? `This will archive ${agentIds.length} agent(s) and close ${terminalIdsToClose.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`
: terminalIdsToClose.length > 0 && filePathsToClose.length > 0
? `This will close ${terminalIdsToClose.length} terminal(s) and close ${filePathsToClose.length} file(s). Any running process in a closed terminal will be stopped immediately.`
: agentIds.length > 0 && filePathsToClose.length > 0
? `This will archive ${agentIds.length} agent(s) and close ${filePathsToClose.length} file(s).`
: terminalIdsToClose.length > 0
? `This will close ${terminalIdsToClose.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`
: filePathsToClose.length > 0
? `This will close ${filePathsToClose.length} file(s).`
: `This will archive ${agentIds.length} agent(s).`,
confirmLabel: "Close",
cancelLabel: "Cancel",
destructive: true,
@@ -1145,11 +1294,21 @@ function WorkspaceScreenContent({
}
}
for (const filePath of filePathsToClose) {
closeFileTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
filePath,
});
}
const resolvedTabKey = resolvedTab?.kind === "agent"
? `agent:${resolvedTab.agentId}`
: resolvedTab?.kind === "terminal"
? `terminal:${resolvedTab.terminalId}`
: null;
: resolvedTab?.kind === "file"
? `file:${resolvedTab.path}`
: null;
const closedKeys = new Set(toClose.map((tab) => tab.key));
if (resolvedTabKey && closedKeys.has(resolvedTabKey)) {
const target = tabByKey.get(tabKey);
@@ -1163,9 +1322,11 @@ function WorkspaceScreenContent({
},
[
archiveAgent,
closeFileTab,
killTerminalMutation,
navigateToTab,
normalizedServerId,
normalizedWorkspaceId,
queryClient,
resolvedTab,
tabByKey,
@@ -1206,6 +1367,16 @@ function WorkspaceScreenContent({
);
}
if (resolvedTab.kind === "file") {
return (
<FilePane
serverId={normalizedServerId}
workspaceRoot={normalizedWorkspaceId}
filePath={resolvedTab.path}
/>
);
}
return (
<TerminalPane
serverId={normalizedServerId}
@@ -1342,6 +1513,10 @@ function WorkspaceScreenContent({
return <Terminal size={14} color={theme.colors.foreground} />;
}
if (activeDescriptor.kind === "file") {
return <FileText size={14} color={theme.colors.foreground} />;
}
const tabAgent = agentsById.get(activeDescriptor.agentId) ?? null;
const tabAgentStatusBucket = tabAgent
? deriveSidebarStateBucket({
@@ -1416,6 +1591,8 @@ function WorkspaceScreenContent({
<TooltipTrigger
testID="workspace-new-terminal-tab"
onPress={() => handleSelectNewTabOption(NEW_TAB_TERMINAL_OPTION_ID)}
onHoverIn={() => setIsNewTerminalHovered(true)}
onHoverOut={() => setIsNewTerminalHovered(false)}
disabled={createTerminalMutation.isPending}
accessibilityRole="button"
accessibilityLabel="New terminal tab"
@@ -1429,8 +1606,8 @@ function WorkspaceScreenContent({
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<View style={styles.terminalPlusIcon}>
<Terminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<View style={styles.terminalPlusBadge}>
<SquareTerminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<View style={[styles.terminalPlusBadge, isNewTerminalHovered && styles.terminalPlusBadgeHovered]}>
<Plus size={10} color={theme.colors.foregroundMuted} />
</View>
</View>
@@ -1527,6 +1704,8 @@ function WorkspaceScreenContent({
/>
) : null}
</View>
) : tab.kind === "file" ? (
<FileText size={14} color={iconColor} />
) : (
<Terminal size={14} color={iconColor} />
);
@@ -1556,6 +1735,10 @@ function WorkspaceScreenContent({
navigateToTab({ kind: "agent", agentId: tab.agentId });
return;
}
if (tab.kind === "file") {
navigateToTab({ kind: "file", path: tab.filePath });
return;
}
navigateToTab({
kind: "terminal",
terminalId: tab.terminalId,
@@ -1587,7 +1770,9 @@ function WorkspaceScreenContent({
testID={
tab.kind === "agent"
? `workspace-agent-close-${tab.agentId}`
: `workspace-terminal-close-${tab.terminalId}`
: tab.kind === "terminal"
? `workspace-terminal-close-${tab.terminalId}`
: `workspace-file-close-${encodeFilePathForPathSegment(tab.filePath)}`
}
pointerEvents={shouldShowCloseButton ? "auto" : "none"}
disabled={!shouldShowCloseButton || isClosingTab}
@@ -1609,6 +1794,10 @@ function WorkspaceScreenContent({
void handleCloseAgentTab(tab.agentId);
return;
}
if (tab.kind === "file") {
handleCloseFileTab(tab.filePath);
return;
}
void handleCloseTerminalTab(tab.terminalId);
}}
style={({ hovered, pressed }) => [
@@ -1676,6 +1865,10 @@ function WorkspaceScreenContent({
void handleCloseAgentTab(tab.agentId);
return;
}
if (tab.kind === "file") {
handleCloseFileTab(tab.filePath);
return;
}
void handleCloseTerminalTab(tab.terminalId);
}}
>
@@ -1709,6 +1902,8 @@ function WorkspaceScreenContent({
<TooltipTrigger
testID="workspace-new-terminal-tab"
onPress={() => handleSelectNewTabOption(NEW_TAB_TERMINAL_OPTION_ID)}
onHoverIn={() => setIsNewTerminalHovered(true)}
onHoverOut={() => setIsNewTerminalHovered(false)}
disabled={createTerminalMutation.isPending}
accessibilityRole="button"
accessibilityLabel="New terminal tab"
@@ -1725,11 +1920,11 @@ function WorkspaceScreenContent({
/>
) : (
<View style={styles.terminalPlusIcon}>
<Terminal
<SquareTerminal
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
<View style={styles.terminalPlusBadge}>
<View style={[styles.terminalPlusBadge, isNewTerminalHovered && styles.terminalPlusBadgeHovered]}>
<Plus size={10} color={theme.colors.foregroundMuted} />
</View>
</View>
@@ -1759,6 +1954,7 @@ function WorkspaceScreenContent({
workspaceId={normalizedWorkspaceId}
workspaceRoot={normalizedWorkspaceId}
isGit={isGitCheckout}
onOpenFile={handleOpenFileFromExplorer}
/>
</View>
</View>
@@ -1832,17 +2028,18 @@ const styles = StyleSheet.create((theme) => ({
},
terminalPlusBadge: {
position: "absolute",
right: -6,
bottom: -6,
width: 14,
height: 14,
borderRadius: 7,
right: -5,
bottom: -5,
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: theme.colors.surface1,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
terminalPlusBadgeHovered: {
backgroundColor: theme.colors.surface2,
},
mobileTabsRow: {
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
@@ -0,0 +1,98 @@
import { create } from "zustand";
import { buildWorkspaceTabPersistenceKey } from "./workspace-tabs-store";
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizeFilePath(value: string | null | undefined): string | null {
const trimmed = trimNonEmpty(value);
if (!trimmed) {
return null;
}
return trimmed.replace(/\\/g, "/");
}
type WorkspaceFileTabsState = {
openFilePathsByWorkspace: Record<string, string[]>;
openFileTab: (input: {
serverId: string;
workspaceId: string;
filePath: string;
}) => void;
closeFileTab: (input: {
serverId: string;
workspaceId: string;
filePath: string;
}) => void;
isFileTabOpen: (input: {
serverId: string;
workspaceId: string;
filePath: string;
}) => boolean;
};
export const useWorkspaceFileTabsStore = create<WorkspaceFileTabsState>()(
(set, get) => ({
openFilePathsByWorkspace: {},
openFileTab: ({ serverId, workspaceId, filePath }) => {
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
const normalizedFilePath = normalizeFilePath(filePath);
if (!key || !normalizedFilePath) {
return;
}
set((state) => {
const current = state.openFilePathsByWorkspace[key] ?? [];
if (current.includes(normalizedFilePath)) {
return state;
}
return {
openFilePathsByWorkspace: {
...state.openFilePathsByWorkspace,
[key]: [...current, normalizedFilePath],
},
};
});
},
closeFileTab: ({ serverId, workspaceId, filePath }) => {
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
const normalizedFilePath = normalizeFilePath(filePath);
if (!key || !normalizedFilePath) {
return;
}
set((state) => {
const current = state.openFilePathsByWorkspace[key] ?? [];
if (current.length === 0 || !current.includes(normalizedFilePath)) {
return state;
}
const next = current.filter((path) => path !== normalizedFilePath);
if (next.length === 0) {
const { [key]: _removed, ...rest } = state.openFilePathsByWorkspace;
return { openFilePathsByWorkspace: rest };
}
return {
openFilePathsByWorkspace: {
...state.openFilePathsByWorkspace,
[key]: next,
},
};
});
},
isFileTabOpen: ({ serverId, workspaceId, filePath }) => {
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
const normalizedFilePath = normalizeFilePath(filePath);
if (!key || !normalizedFilePath) {
return false;
}
const current = get().openFilePathsByWorkspace[key] ?? [];
return current.includes(normalizedFilePath);
},
})
);
@@ -4,7 +4,8 @@ import { createJSONStorage, persist } from "zustand/middleware";
export type WorkspaceTabTarget =
| { kind: "agent"; agentId: string }
| { kind: "terminal"; terminalId: string };
| { kind: "terminal"; terminalId: string }
| { kind: "file"; path: string };
function normalizeKeys(keys: string[]): string[] {
const seen = new Set<string>();
@@ -50,6 +51,10 @@ function normalizeWorkspaceTab(
}
return { kind: "terminal", terminalId };
}
if (value.kind === "file") {
// File tabs are session-only; do not persist in workspace tab memory.
return null;
}
return null;
}
+54
View File
@@ -144,6 +144,33 @@ const lightSemanticColors = {
accentBorder: "#ececf1",
input: "#f4f4f5",
ring: "#18181b",
terminal: {
background: "#ffffff",
foreground: "#09090b",
cursor: "#09090b",
cursorAccent: "#ffffff",
selectionBackground: "rgba(0, 0, 0, 0.15)",
selectionForeground: "#09090b",
black: "#09090b",
red: "#dc2626",
green: "#16a34a",
yellow: "#ca8a04",
blue: "#2563eb",
magenta: "#9333ea",
cyan: "#0891b2",
white: "#ffffff",
brightBlack: "#3f3f46",
brightRed: "#ef4444",
brightGreen: "#22c55e",
brightYellow: "#f59e0b",
brightBlue: "#3b82f6",
brightMagenta: "#a855f7",
brightCyan: "#06b6d4",
brightWhite: "#fafafa",
},
} as const;
const darkSemanticColors = {
@@ -186,6 +213,33 @@ const darkSemanticColors = {
accentBorder: "#34343a",
input: "#27272a",
ring: "#d4d4d8",
terminal: {
background: "#18181c",
foreground: "#fafafa",
cursor: "#fafafa",
cursorAccent: "#18181c",
selectionBackground: "rgba(255, 255, 255, 0.2)",
selectionForeground: "#fafafa",
black: "#121214",
red: "#ef4444",
green: "#22c55e",
yellow: "#f59e0b",
blue: "#3b82f6",
magenta: "#a855f7",
cyan: "#06b6d4",
white: "#e4e4e7",
brightBlack: "#3f3f46",
brightRed: "#f87171",
brightGreen: "#4ade80",
brightYellow: "#fbbf24",
brightBlue: "#60a5fa",
brightMagenta: "#c084fc",
brightCyan: "#22d3ee",
brightWhite: "#ffffff",
},
} as const;
const commonTheme = {
@@ -1,5 +1,7 @@
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal, type ITheme } from "@xterm/xterm";
import {
type PendingTerminalModifiers,
isTerminalModifierDomKey,
@@ -10,17 +12,11 @@ import {
} from "@/utils/terminal-keys";
import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
export type TerminalEmulatorRuntimeTheme = {
backgroundColor: string;
foregroundColor: string;
cursorColor: string;
};
export type TerminalEmulatorRuntimeMountInput = {
root: HTMLDivElement;
host: HTMLDivElement;
initialOutputText: string;
theme: TerminalEmulatorRuntimeTheme;
theme: ITheme;
};
export type TerminalEmulatorRuntimeCallbacks = {
@@ -48,12 +44,14 @@ type TerminalEmulatorRuntimeDisposables = {
restoreDocumentStyles: () => void;
restoreViewportStyles: () => void;
disposeFitAddon: () => void;
disposeWebglAddon: () => void;
disposeTerminal: () => void;
};
type TerminalOutputOperation = {
type: "write" | "clear";
text: string;
suppressInput?: boolean;
onCommitted?: () => void;
};
@@ -67,6 +65,26 @@ const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
const DEFAULT_TERMINAL_FONT_FAMILY = [
// Prefer common developer fonts, with Nerd Font variants for prompt/TUI glyphs.
"JetBrains Mono",
"JetBrainsMono Nerd Font",
"JetBrainsMono NF",
"MesloLGM Nerd Font",
"MesloLGM NF",
"Hack Nerd Font",
"FiraCode Nerd Font",
// PUA-only fallback (many Nerd glyphs live here on some systems).
"Symbols Nerd Font",
// System fallbacks.
"SF Mono",
"Menlo",
"Monaco",
"Consolas",
"'Liberation Mono'",
"monospace",
].join(", ");
export class TerminalEmulatorRuntime {
private callbacks: TerminalEmulatorRuntimeCallbacks = {};
private pendingModifiers: PendingTerminalModifiers = {
@@ -82,6 +100,7 @@ export class TerminalEmulatorRuntime {
private outputOperations: TerminalOutputOperation[] = [];
private inFlightOutputOperation: TerminalOutputOperation | null = null;
private inFlightOutputOperationTimeout: ReturnType<typeof setTimeout> | null = null;
private suppressInput = false;
setCallbacks(input: { callbacks: TerminalEmulatorRuntimeCallbacks }): void {
this.callbacks = input.callbacks;
@@ -109,19 +128,49 @@ export class TerminalEmulatorRuntime {
convertEol: false,
cursorBlink: true,
cursorStyle: "bar",
fontFamily: "'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', monospace",
fontFamily: DEFAULT_TERMINAL_FONT_FAMILY,
fontSize: 13,
lineHeight: 1.25,
lineHeight: 1.0,
scrollback: 10_000,
theme: {
background: input.theme.backgroundColor,
foreground: input.theme.foregroundColor,
cursor: input.theme.cursorColor,
},
theme: input.theme,
});
const fitAddon = new FitAddon();
const unicode11Addon = new Unicode11Addon();
let webglAddon: WebglAddon | null = null;
terminal.loadAddon(fitAddon);
terminal.loadAddon(unicode11Addon);
terminal.open(input.host);
try {
terminal.unicode.activeVersion = "11";
} catch {
// Ignore if unicode API isn't available in this build/runtime.
}
// Prefer GPU rendering when available. This tends to reduce visible seams in block characters
// and improves scroll performance, but must gracefully fall back on platforms without WebGL.
let webglAddonRaf: number | null = requestAnimationFrame(() => {
webglAddonRaf = null;
try {
webglAddon = new WebglAddon();
webglAddon.onContextLoss(() => {
try {
webglAddon?.dispose();
} catch {
// ignore
}
webglAddon = null;
// Force a refresh after context loss to avoid visual corruption.
try {
terminal.refresh(0, terminal.rows - 1);
} catch {
// ignore
}
});
terminal.loadAddon(webglAddon);
} catch {
webglAddon = null;
}
});
const restoreDocumentStyles = this.applyDocumentBoundsStyles({
root: input.root,
@@ -174,6 +223,17 @@ export class TerminalEmulatorRuntime {
fitAndEmitResize(true);
const inputDisposable = terminal.onData((data) => {
if (this.suppressInput) {
terminalDebugLog({
scope: "emulator-runtime",
event: "input:suppressed",
details: {
length: data.length,
preview: summarizeTerminalText({ text: data, maxChars: 64 }),
},
});
return;
}
terminalDebugLog({
scope: "emulator-runtime",
event: "input:onData",
@@ -283,7 +343,7 @@ export class TerminalEmulatorRuntime {
}, 0);
if (input.initialOutputText.length > 0) {
terminal.write(input.initialOutputText);
this.write({ text: input.initialOutputText, suppressInput: true });
terminalDebugLog({
scope: "emulator-runtime",
event: "output:initial-write",
@@ -329,6 +389,18 @@ export class TerminalEmulatorRuntime {
disposeFitAddon: () => {
fitAddon.dispose();
},
disposeWebglAddon: () => {
if (webglAddonRaf !== null) {
cancelAnimationFrame(webglAddonRaf);
webglAddonRaf = null;
}
try {
webglAddon?.dispose();
} catch {
// ignore
}
webglAddon = null;
},
disposeTerminal: () => {
terminal.dispose();
},
@@ -344,13 +416,14 @@ export class TerminalEmulatorRuntime {
disposables.removeFontListeners();
disposables.removeTouchListeners();
disposables.disposeFitAddon();
disposables.disposeWebglAddon();
disposables.disposeTerminal();
disposables.restoreDocumentStyles();
disposables.restoreViewportStyles();
};
}
write(input: { text: string; onCommitted?: () => void }): void {
write(input: { text: string; suppressInput?: boolean; onCommitted?: () => void }): void {
if (input.text.length === 0) {
input.onCommitted?.();
return;
@@ -358,6 +431,7 @@ export class TerminalEmulatorRuntime {
this.outputOperations.push({
type: "write",
text: input.text,
suppressInput: input.suppressInput ?? false,
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
terminalDebugLog({
@@ -376,6 +450,7 @@ export class TerminalEmulatorRuntime {
this.outputOperations.push({
type: "clear",
text: "",
suppressInput: false,
...(input?.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
terminalDebugLog({
@@ -421,6 +496,7 @@ export class TerminalEmulatorRuntime {
this.fitAddon = null;
this.fitAndEmitResize = null;
this.lastSize = null;
this.suppressInput = false;
}
private processOutputQueue(): void {
@@ -439,12 +515,17 @@ export class TerminalEmulatorRuntime {
}
this.inFlightOutputOperation = operation;
const previousSuppressInput = this.suppressInput;
if (operation.type === "write") {
this.suppressInput = Boolean(operation.suppressInput);
}
const finalizeOperation = (expectedOperation: TerminalOutputOperation) => {
if (this.inFlightOutputOperation !== expectedOperation) {
return;
}
this.inFlightOutputOperation = null;
this.clearInFlightOutputTimeout();
this.suppressInput = previousSuppressInput;
expectedOperation.onCommitted?.();
this.processOutputQueue();
};
@@ -493,12 +574,19 @@ export class TerminalEmulatorRuntime {
const previousDocumentElementOverflow = documentElement.style.overflow;
const previousDocumentElementWidth = documentElement.style.width;
const previousDocumentElementHeight = documentElement.style.height;
const previousDocumentElementTextSizeAdjust =
documentElement.style.getPropertyValue("text-size-adjust");
const previousDocumentElementWebkitTextSizeAdjust =
documentElement.style.getPropertyValue("-webkit-text-size-adjust");
const previousBodyOverflow = body.style.overflow;
const previousBodyWidth = body.style.width;
const previousBodyHeight = body.style.height;
const previousBodyMargin = body.style.margin;
const previousBodyPadding = body.style.padding;
const previousBodyTextSizeAdjust = body.style.getPropertyValue("text-size-adjust");
const previousBodyWebkitTextSizeAdjust =
body.style.getPropertyValue("-webkit-text-size-adjust");
const previousRootOverflow = rootContainer?.style.overflow ?? "";
const previousRootWidth = rootContainer?.style.width ?? "";
@@ -507,12 +595,16 @@ export class TerminalEmulatorRuntime {
documentElement.style.overflow = "hidden";
documentElement.style.width = "100%";
documentElement.style.height = "100%";
documentElement.style.setProperty("text-size-adjust", "100%");
documentElement.style.setProperty("-webkit-text-size-adjust", "100%");
body.style.overflow = "hidden";
body.style.width = "100%";
body.style.height = "100%";
body.style.margin = "0";
body.style.padding = "0";
body.style.setProperty("text-size-adjust", "100%");
body.style.setProperty("-webkit-text-size-adjust", "100%");
if (rootContainer) {
rootContainer.style.overflow = "hidden";
@@ -524,12 +616,19 @@ export class TerminalEmulatorRuntime {
documentElement.style.overflow = previousDocumentElementOverflow;
documentElement.style.width = previousDocumentElementWidth;
documentElement.style.height = previousDocumentElementHeight;
documentElement.style.setProperty("text-size-adjust", previousDocumentElementTextSizeAdjust);
documentElement.style.setProperty(
"-webkit-text-size-adjust",
previousDocumentElementWebkitTextSizeAdjust
);
body.style.overflow = previousBodyOverflow;
body.style.width = previousBodyWidth;
body.style.height = previousBodyHeight;
body.style.margin = previousBodyMargin;
body.style.padding = previousBodyPadding;
body.style.setProperty("text-size-adjust", previousBodyTextSizeAdjust);
body.style.setProperty("-webkit-text-size-adjust", previousBodyWebkitTextSizeAdjust);
if (rootContainer) {
rootContainer.style.overflow = previousRootOverflow;
@@ -5,7 +5,7 @@ import { TerminalOutputDeliveryQueue } from "./terminal-output-delivery-queue";
describe("terminal-output-delivery-queue", () => {
it("retries in-flight delivery when consume is missing", () => {
vi.useFakeTimers();
const delivered: Array<{ sequence: number; text: string }> = [];
const delivered: Array<{ sequence: number; text: string; replay: boolean }> = [];
const queue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk) => {
delivered.push(chunk);
@@ -13,45 +13,45 @@ describe("terminal-output-delivery-queue", () => {
deliveryTimeoutMs: 100,
});
queue.enqueue({ sequence: 1, text: "a" });
queue.enqueue({ sequence: 2, text: "b" });
queue.enqueue({ sequence: 1, text: "a", replay: false });
queue.enqueue({ sequence: 2, text: "b", replay: false });
expect(delivered).toEqual([{ sequence: 1, text: "a" }]);
expect(delivered).toEqual([{ sequence: 1, text: "a", replay: false }]);
vi.advanceTimersByTime(100);
expect(delivered).toEqual([
{ sequence: 1, text: "a" },
{ sequence: 1, text: "a" },
{ sequence: 1, text: "a", replay: false },
{ sequence: 1, text: "a", replay: false },
]);
queue.consume({ sequence: 1 });
expect(delivered).toEqual([
{ sequence: 1, text: "a" },
{ sequence: 1, text: "a" },
{ sequence: 2, text: "b" },
{ sequence: 1, text: "a", replay: false },
{ sequence: 1, text: "a", replay: false },
{ sequence: 2, text: "b", replay: false },
]);
vi.useRealTimers();
});
it("delivers first chunk immediately and blocks later chunks until consumed", () => {
const delivered: Array<{ sequence: number; text: string }> = [];
const delivered: Array<{ sequence: number; text: string; replay: boolean }> = [];
const queue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk) => {
delivered.push(chunk);
},
});
queue.enqueue({ sequence: 1, text: "a" });
queue.enqueue({ sequence: 2, text: "b" });
queue.enqueue({ sequence: 3, text: "c" });
queue.enqueue({ sequence: 1, text: "a", replay: false });
queue.enqueue({ sequence: 2, text: "b", replay: false });
queue.enqueue({ sequence: 3, text: "c", replay: false });
expect(delivered).toEqual([{ sequence: 1, text: "a" }]);
expect(delivered).toEqual([{ sequence: 1, text: "a", replay: false }]);
queue.consume({ sequence: 1 });
expect(delivered).toEqual([
{ sequence: 1, text: "a" },
{ sequence: 3, text: "bc" },
{ sequence: 1, text: "a", replay: false },
{ sequence: 3, text: "bc", replay: false },
]);
});
@@ -59,83 +59,83 @@ describe("terminal-output-delivery-queue", () => {
const delivered = vi.fn();
const queue = new TerminalOutputDeliveryQueue({ onDeliver: delivered });
queue.enqueue({ sequence: 1, text: "x" });
queue.enqueue({ sequence: 1, text: "x", replay: false });
queue.consume({ sequence: 99 });
queue.enqueue({ sequence: 2, text: "y" });
queue.enqueue({ sequence: 2, text: "y", replay: false });
expect(delivered).toHaveBeenCalledTimes(1);
expect(delivered).toHaveBeenNthCalledWith(1, { sequence: 1, text: "x" });
expect(delivered).toHaveBeenNthCalledWith(1, { sequence: 1, text: "x", replay: false });
queue.consume({ sequence: 1 });
expect(delivered).toHaveBeenCalledTimes(2);
expect(delivered).toHaveBeenNthCalledWith(2, { sequence: 2, text: "y" });
expect(delivered).toHaveBeenNthCalledWith(2, { sequence: 2, text: "y", replay: false });
});
it("resets in-flight and pending chunks", () => {
const delivered: Array<{ sequence: number; text: string }> = [];
const delivered: Array<{ sequence: number; text: string; replay: boolean }> = [];
const queue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk) => {
delivered.push(chunk);
},
});
queue.enqueue({ sequence: 1, text: "hello" });
queue.enqueue({ sequence: 2, text: " world" });
queue.enqueue({ sequence: 1, text: "hello", replay: false });
queue.enqueue({ sequence: 2, text: " world", replay: false });
queue.reset();
queue.enqueue({ sequence: 3, text: "next" });
queue.enqueue({ sequence: 3, text: "next", replay: false });
expect(delivered).toEqual([
{ sequence: 1, text: "hello" },
{ sequence: 3, text: "next" },
{ sequence: 1, text: "hello", replay: false },
{ sequence: 3, text: "next", replay: false },
]);
});
it("preserves empty chunk payloads for authoritative clears", () => {
const delivered: Array<{ sequence: number; text: string }> = [];
const delivered: Array<{ sequence: number; text: string; replay: boolean }> = [];
const queue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk) => {
delivered.push(chunk);
},
});
queue.enqueue({ sequence: 1, text: "abc" });
queue.enqueue({ sequence: 1, text: "abc", replay: false });
queue.consume({ sequence: 1 });
queue.enqueue({ sequence: 2, text: "" });
queue.enqueue({ sequence: 2, text: "", replay: false });
expect(delivered).toEqual([
{ sequence: 1, text: "abc" },
{ sequence: 2, text: "" },
{ sequence: 1, text: "abc", replay: false },
{ sequence: 2, text: "", replay: false },
]);
});
it("treats clear chunks as a delivery barrier and drops pending stale text", () => {
const delivered: Array<{ sequence: number; text: string }> = [];
const delivered: Array<{ sequence: number; text: string; replay: boolean }> = [];
const queue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk) => {
delivered.push(chunk);
},
});
queue.enqueue({ sequence: 1, text: "a" });
queue.enqueue({ sequence: 2, text: "b" });
queue.enqueue({ sequence: 3, text: "" });
queue.enqueue({ sequence: 4, text: "c" });
queue.enqueue({ sequence: 1, text: "a", replay: false });
queue.enqueue({ sequence: 2, text: "b", replay: false });
queue.enqueue({ sequence: 3, text: "", replay: false });
queue.enqueue({ sequence: 4, text: "c", replay: false });
expect(delivered).toEqual([{ sequence: 1, text: "a" }]);
expect(delivered).toEqual([{ sequence: 1, text: "a", replay: false }]);
queue.consume({ sequence: 1 });
expect(delivered).toEqual([
{ sequence: 1, text: "a" },
{ sequence: 3, text: "" },
{ sequence: 1, text: "a", replay: false },
{ sequence: 3, text: "", replay: false },
]);
queue.consume({ sequence: 3 });
expect(delivered).toEqual([
{ sequence: 1, text: "a" },
{ sequence: 3, text: "" },
{ sequence: 4, text: "c" },
{ sequence: 1, text: "a", replay: false },
{ sequence: 3, text: "", replay: false },
{ sequence: 4, text: "c", replay: false },
]);
});
});
@@ -3,6 +3,7 @@ import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
export type TerminalOutputDeliveryChunk = {
sequence: number;
text: string;
replay: boolean;
};
export type TerminalOutputDeliveryQueueOptions = {
@@ -48,7 +49,11 @@ export class TerminalOutputDeliveryQueue {
}
const lastPendingChunk = this.pendingChunks[this.pendingChunks.length - 1];
if (lastPendingChunk && lastPendingChunk.text.length > 0) {
if (
lastPendingChunk &&
lastPendingChunk.text.length > 0 &&
lastPendingChunk.replay === chunk.replay
) {
lastPendingChunk.sequence = chunk.sequence;
lastPendingChunk.text += chunk.text;
} else {
@@ -138,6 +143,7 @@ export class TerminalOutputDeliveryQueue {
this.options.onDeliver({
sequence: chunk.sequence,
text: chunk.text,
replay: chunk.replay,
});
}
@@ -5,7 +5,7 @@ import { TerminalOutputPump } from "./terminal-output-pump";
describe("terminal-output-pump", () => {
it("batches selected-terminal chunk bursts into ordered flushes", () => {
vi.useFakeTimers();
const chunks: Array<{ sequence: number; text: string }> = [];
const chunks: Array<{ sequence: number; text: string; replay: boolean }> = [];
const pump = new TerminalOutputPump({
maxOutputChars: 100,
onSelectedOutputChunk: (chunk) => {
@@ -14,16 +14,16 @@ describe("terminal-output-pump", () => {
});
pump.setSelectedTerminal({ terminalId: "term-1" });
pump.append({ terminalId: "term-1", text: "a" });
pump.append({ terminalId: "term-1", text: "b" });
pump.append({ terminalId: "term-1", text: "c" });
pump.append({ terminalId: "term-1", text: "a", replay: false });
pump.append({ terminalId: "term-1", text: "b", replay: false });
pump.append({ terminalId: "term-1", text: "c", replay: false });
expect(chunks).toEqual([]);
vi.runOnlyPendingTimers();
expect(chunks).toEqual([
{ sequence: 1, text: "abc" },
{ sequence: 1, text: "abc", replay: false },
]);
vi.useRealTimers();
@@ -31,7 +31,7 @@ describe("terminal-output-pump", () => {
it("keeps per-terminal snapshots and switches selected stream deterministically", () => {
vi.useFakeTimers();
const chunks: Array<{ sequence: number; text: string }> = [];
const chunks: Array<{ sequence: number; text: string; replay: boolean }> = [];
const pump = new TerminalOutputPump({
maxOutputChars: 10,
onSelectedOutputChunk: (chunk) => {
@@ -40,21 +40,21 @@ describe("terminal-output-pump", () => {
});
pump.setSelectedTerminal({ terminalId: "term-1" });
pump.append({ terminalId: "term-1", text: "hello" });
pump.append({ terminalId: "term-1", text: "hello", replay: false });
vi.runOnlyPendingTimers();
expect(pump.readSnapshot({ terminalId: "term-1" })).toBe("hello");
pump.append({ terminalId: "term-2", text: "world" });
pump.append({ terminalId: "term-2", text: "world", replay: false });
vi.runOnlyPendingTimers();
expect(pump.readSnapshot({ terminalId: "term-2" })).toBe("world");
pump.setSelectedTerminal({ terminalId: "term-2" });
pump.append({ terminalId: "term-2", text: "!" });
pump.append({ terminalId: "term-2", text: "!", replay: false });
vi.runOnlyPendingTimers();
expect(chunks).toEqual([
{ sequence: 1, text: "hello" },
{ sequence: 2, text: "!" },
{ sequence: 1, text: "hello", replay: false },
{ sequence: 2, text: "!", replay: false },
]);
vi.useRealTimers();
@@ -62,7 +62,7 @@ describe("terminal-output-pump", () => {
it("resets selected output when clearing selected terminal", () => {
vi.useFakeTimers();
const chunks: Array<{ sequence: number; text: string }> = [];
const chunks: Array<{ sequence: number; text: string; replay: boolean }> = [];
const pump = new TerminalOutputPump({
maxOutputChars: 10,
onSelectedOutputChunk: (chunk) => {
@@ -71,15 +71,15 @@ describe("terminal-output-pump", () => {
});
pump.setSelectedTerminal({ terminalId: "term-1" });
pump.append({ terminalId: "term-1", text: "abc" });
pump.append({ terminalId: "term-1", text: "abc", replay: false });
vi.runOnlyPendingTimers();
pump.clearTerminal({ terminalId: "term-1" });
expect(pump.readSnapshot({ terminalId: "term-1" })).toBe("");
expect(chunks).toEqual([
{ sequence: 1, text: "abc" },
{ sequence: 2, text: "" },
{ sequence: 1, text: "abc", replay: false },
{ sequence: 2, text: "", replay: false },
]);
vi.useRealTimers();
@@ -91,8 +91,8 @@ describe("terminal-output-pump", () => {
onSelectedOutputChunk: () => {},
});
pump.append({ terminalId: "a", text: "one" });
pump.append({ terminalId: "b", text: "two" });
pump.append({ terminalId: "a", text: "one", replay: false });
pump.append({ terminalId: "b", text: "two", replay: false });
pump.prune({ terminalIds: ["b"] });
expect(pump.readSnapshot({ terminalId: "a" })).toBe("");
@@ -9,6 +9,7 @@ import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
export type TerminalOutputChunk = {
sequence: number;
text: string;
replay: boolean;
};
export type TerminalOutputPumpOptions = {
@@ -23,6 +24,7 @@ export type TerminalOutputPumpSetSelectedInput = {
export type TerminalOutputPumpAppendInput = {
terminalId: string;
text: string;
replay: boolean;
};
export type TerminalOutputPumpReadInput = {
@@ -42,6 +44,7 @@ export class TerminalOutputPump {
private selectedTerminalId: string | null = null;
private selectedChunkSequence = 0;
private selectedChunkAccumulator = "";
private selectedChunkAccumulatorReplay: boolean | null = null;
private selectedChunkFlushTimer: ReturnType<typeof setTimeout> | null = null;
constructor(private readonly options: TerminalOutputPumpOptions) {}
@@ -61,6 +64,7 @@ export class TerminalOutputPump {
});
this.clearSelectedChunkFlushTimer();
this.selectedChunkAccumulator = "";
this.selectedChunkAccumulatorReplay = null;
this.selectedTerminalId = input.terminalId;
}
@@ -85,6 +89,18 @@ export class TerminalOutputPump {
return;
}
if (
this.selectedChunkAccumulator.length > 0 &&
typeof this.selectedChunkAccumulatorReplay === "boolean" &&
this.selectedChunkAccumulatorReplay !== input.replay
) {
this.flushSelectedChunkAccumulator();
}
if (this.selectedChunkAccumulator.length === 0) {
this.selectedChunkAccumulatorReplay = input.replay;
}
this.selectedChunkAccumulator += input.text;
terminalDebugLog({
scope: "output-pump",
@@ -93,6 +109,7 @@ export class TerminalOutputPump {
terminalId: input.terminalId,
appendedLength: input.text.length,
accumulatorLength: this.selectedChunkAccumulator.length,
replay: input.replay,
preview: summarizeTerminalText({ text: input.text, maxChars: 80 }),
},
});
@@ -104,7 +121,8 @@ export class TerminalOutputPump {
if (this.selectedTerminalId === input.terminalId) {
this.clearSelectedChunkFlushTimer();
this.selectedChunkAccumulator = "";
this.emitSelectedChunk({ text: "" });
this.selectedChunkAccumulatorReplay = null;
this.emitSelectedChunk({ text: "", replay: false });
}
}
@@ -152,24 +170,28 @@ export class TerminalOutputPump {
}
const text = this.selectedChunkAccumulator;
const replay = this.selectedChunkAccumulatorReplay ?? false;
this.selectedChunkAccumulator = "";
this.selectedChunkAccumulatorReplay = null;
terminalDebugLog({
scope: "output-pump",
event: "selected-terminal:flush",
details: {
terminalId: this.selectedTerminalId,
textLength: text.length,
replay,
preview: summarizeTerminalText({ text, maxChars: 96 }),
},
});
this.emitSelectedChunk({ text });
this.emitSelectedChunk({ text, replay });
}
private emitSelectedChunk(input: { text: string }): void {
private emitSelectedChunk(input: { text: string; replay: boolean }): void {
this.selectedChunkSequence += 1;
this.options.onSelectedOutputChunk({
sequence: this.selectedChunkSequence,
text: input.text,
replay: input.replay,
});
}
@@ -54,7 +54,7 @@ export type TerminalStreamControllerStatus = {
export type TerminalStreamControllerOptions = {
client: TerminalStreamControllerClient;
getPreferredSize: () => TerminalStreamControllerSize | null;
onChunk: (input: { terminalId: string; text: string }) => void;
onChunk: (input: { terminalId: string; text: string; replay: boolean }) => void;
onReset?: (input: { terminalId: string }) => void;
onStatusChange?: (status: TerminalStreamControllerStatus) => void;
maxAttachAttempts?: number;
@@ -500,6 +500,7 @@ export class TerminalStreamController {
this.options.onChunk({
terminalId: input.terminalId,
text,
replay: Boolean(input.chunk.replay),
});
}
@@ -565,6 +566,7 @@ export class TerminalStreamController {
this.options.onChunk({
terminalId: activeStream.terminalId,
text: tail,
replay: false,
});
}
} catch {
@@ -1,12 +1,16 @@
import { describe, expect, it } from "vitest";
import {
buildHostWorkspaceRoute,
buildHostWorkspaceFileRoute,
decodeFilePathFromPathSegment,
decodeWorkspaceIdFromPathSegment,
encodeFilePathForPathSegment,
encodeWorkspaceIdForPathSegment,
parseHostAgentDraftRouteFromPathname,
parseHostAgentRouteFromPathname,
parseHostDraftRouteFromPathname,
parseHostWorkspaceAgentRouteFromPathname,
parseHostWorkspaceFileRouteFromPathname,
parseHostWorkspaceTerminalRouteFromPathname,
parseHostWorkspaceRouteFromPathname,
} from "./host-routes";
@@ -54,6 +58,12 @@ describe("workspace route parsing", () => {
expect(decodeWorkspaceIdFromPathSegment("L3RtcC9yZXBv")).toBe("/tmp/repo");
});
it("encodes file paths as base64url (no padding)", () => {
const encoded = encodeFilePathForPathSegment("src/index.ts");
expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/);
expect(decodeFilePathFromPathSegment(encoded)).toBe("src/index.ts");
});
it("parses workspace route", () => {
expect(
parseHostWorkspaceRouteFromPathname("/h/local/workspace/L3RtcC9yZXBv")
@@ -63,6 +73,19 @@ describe("workspace route parsing", () => {
});
});
it("parses workspace file route", () => {
const encodedPath = encodeFilePathForPathSegment("src/index.ts");
expect(
parseHostWorkspaceFileRouteFromPathname(
`/h/local/workspace/L3RtcC9yZXBv/file/${encodedPath}`
)
).toEqual({
serverId: "local",
workspaceId: "/tmp/repo",
filePath: "src/index.ts",
});
});
it("parses workspace agent route", () => {
expect(
parseHostWorkspaceAgentRouteFromPathname(
@@ -104,4 +127,10 @@ describe("workspace route parsing", () => {
"/h/local/workspace/L3RtcC9yZXBv"
);
});
it("builds base64url workspace file routes", () => {
expect(buildHostWorkspaceFileRoute("local", "/tmp/repo", "src/index.ts")).toBe(
`/h/local/workspace/L3RtcC9yZXBv/file/${encodeFilePathForPathSegment("src/index.ts")}`
);
});
});
+86
View File
@@ -94,6 +94,26 @@ export function decodeWorkspaceIdFromPathSegment(workspaceIdSegment: string): st
return normalizeWorkspaceId(decoded);
}
export function encodeFilePathForPathSegment(filePath: string): string {
const normalized = trimNonEmpty(filePath);
if (!normalized) {
return "";
}
return toBase64UrlNoPad(normalized);
}
export function decodeFilePathFromPathSegment(filePathSegment: string): string | null {
const normalizedSegment = trimNonEmpty(filePathSegment);
if (!normalizedSegment) {
return null;
}
const decoded = trimNonEmpty(decodeSegment(normalizedSegment));
if (!decoded) {
return null;
}
return tryDecodeBase64UrlNoPadUtf8(decoded);
}
export function parseServerIdFromPathname(pathname: string): string | null {
const match = pathname.match(/^\/h\/([^/]+)(?:\/|$)/);
if (!match) {
@@ -211,6 +231,10 @@ export function parseHostWorkspaceRouteFromPathname(
if (terminalIdx >= 0 && terminalIdx > workspaceIdStart) {
workspaceIdEnd = Math.min(workspaceIdEnd, terminalIdx);
}
const fileIdx = pathname.lastIndexOf("/file/");
if (fileIdx >= 0 && fileIdx > workspaceIdStart) {
workspaceIdEnd = Math.min(workspaceIdEnd, fileIdx);
}
const rawWorkspaceId = pathname.slice(workspaceIdStart, workspaceIdEnd).replace(/\/+$/, "");
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
@@ -320,6 +344,55 @@ export function parseHostWorkspaceTerminalRouteFromPathname(
return { serverId, workspaceId, terminalId };
}
export function parseHostWorkspaceFileRouteFromPathname(
pathname: string
): { serverId: string; workspaceId: string; filePath: string } | null {
const prefix = "/h/";
if (!pathname.startsWith(prefix)) {
return null;
}
const serverIdStart = prefix.length;
const serverIdEnd = pathname.indexOf("/", serverIdStart);
if (serverIdEnd < 0) {
return null;
}
const rawServerId = pathname.slice(serverIdStart, serverIdEnd);
const serverId = trimNonEmpty(decodeSegment(rawServerId));
if (!serverId) {
return null;
}
const workspacePrefix = "/workspace/";
if (!pathname.startsWith(workspacePrefix, serverIdEnd)) {
return null;
}
const workspaceIdStart = serverIdEnd + workspacePrefix.length;
const fileMarker = "/file/";
const fileIdx = pathname.lastIndexOf(fileMarker);
if (fileIdx < 0 || fileIdx <= workspaceIdStart) {
return null;
}
const rawWorkspaceId = pathname.slice(workspaceIdStart, fileIdx).replace(/\/+$/, "");
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
if (!workspaceId) {
return null;
}
const filePathStart = fileIdx + fileMarker.length;
const filePathEnd = pathname.indexOf("/", filePathStart);
const rawFilePath =
filePathEnd < 0 ? pathname.slice(filePathStart) : pathname.slice(filePathStart, filePathEnd);
const filePath = decodeFilePathFromPathSegment(rawFilePath);
if (!filePath) {
return null;
}
return { serverId, workspaceId, filePath };
}
export function buildHostWorkspaceRoute(
serverId: string,
workspaceId: string
@@ -362,6 +435,19 @@ export function buildHostWorkspaceTerminalRoute(
return `${base}/terminal/${encodeSegment(normalizedTerminalId)}`;
}
export function buildHostWorkspaceFileRoute(
serverId: string,
workspaceId: string,
filePath: string
): string {
const base = buildHostWorkspaceRoute(serverId, workspaceId);
const encodedFilePath = encodeFilePathForPathSegment(filePath);
if (base === "/" || !encodedFilePath) {
return "/";
}
return `${base}/file/${encodeSegment(encodedFilePath)}`;
}
export function buildHostAgentDetailRoute(
serverId: string,
agentId: string,
+35
View File
@@ -0,0 +1,35 @@
import type { ITheme } from "@xterm/xterm";
import type { Theme } from "@/styles/theme";
type TerminalPalette = Theme["colors"]["terminal"];
export function toXtermTheme(terminal: TerminalPalette): ITheme {
return {
background: terminal.background,
foreground: terminal.foreground,
cursor: terminal.cursor,
cursorAccent: terminal.cursorAccent,
selectionBackground: terminal.selectionBackground,
selectionForeground: terminal.selectionForeground,
black: terminal.black,
red: terminal.red,
green: terminal.green,
yellow: terminal.yellow,
blue: terminal.blue,
magenta: terminal.magenta,
cyan: terminal.cyan,
white: terminal.white,
brightBlack: terminal.brightBlack,
brightRed: terminal.brightRed,
brightGreen: terminal.brightGreen,
brightYellow: terminal.brightYellow,
brightBlue: terminal.brightBlue,
brightMagenta: terminal.brightMagenta,
brightCyan: terminal.brightCyan,
brightWhite: terminal.brightWhite,
};
}
+1 -2
View File
@@ -9,7 +9,6 @@
"tauri": "tauri"
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.6",
"@tauri-apps/cli-linux-x64-gnu": "^2.9.6"
"@tauri-apps/cli": "^2.9.6"
}
}