diff --git a/package-lock.json b/package-lock.json
index 4de9630ce..4703178c3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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": {
diff --git a/packages/app/package.json b/packages/app/package.json
index 204173c67..88d532537 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -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",
diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/file/[fileId].tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/file/[fileId].tsx
new file mode 100644
index 000000000..0516169d7
--- /dev/null
+++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/file/[fileId].tsx
@@ -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 (
+
+ );
+}
+
diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx
index a14e571b4..df7908374 100644
--- a/packages/app/src/components/agent-stream-view.tsx
+++ b/packages/app/src/components/agent-stream-view.tsx
@@ -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(null);
const bottomAnchorRef = useRef(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({
// 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(" ");
diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx
index cf4c5ff0f..b4a3e578f 100644
--- a/packages/app/src/components/explorer-sidebar.tsx
+++ b/packages/app/src/components/explorer-sidebar.tsx
@@ -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}
/>
@@ -324,6 +327,7 @@ export function ExplorerSidebar({
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile={false}
+ onOpenFile={onOpenFile}
/>
);
@@ -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}
/>
)}
diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx
index f5d23f755..ee05a72ff 100644
--- a/packages/app/src/components/file-explorer-pane.tsx
+++ b/packages/app/src/components/file-explorer-pane.tsx
@@ -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>(() => new Set(["."]));
- const [containerWidth, setContainerWidth] = useState(0);
- const wasInlinePreviewVisibleRef = useRef(false);
const treeListRef = useRef>(null);
const treeScrollbarMetrics = useWebDesktopScrollbarMetrics();
- // Bottom sheet for file preview (mobile)
- const previewSheetRef = useRef(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) => {
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) => (
-
- ),
- []
- );
-
const handleBackFromError = useCallback(() => {
if (!hasWorkspaceScope) {
return;
@@ -667,7 +528,6 @@ export function FileExplorerPane({
return (
setContainerWidth(event.nativeEvent.layout.width)}
>
{error ? (
@@ -701,385 +561,66 @@ export function FileExplorerPane({
No files
) : (
-
- {shouldShowInlinePreview ? (
-
-
-
- {selectedEntryPath?.split("/").pop() ?? "Preview"}
-
-
- {isPreviewLoading ? (
-
- ) : null}
- [
- styles.iconButton,
- (hovered || pressed) && styles.iconButtonHovered,
- ]}
- accessibilityRole="button"
- accessibilityLabel="Close preview"
- >
-
-
-
-
-
-
+
+
+
+
+ [
+ styles.iconButton,
+ (hovered || pressed) && styles.iconButtonHovered,
+ pressed && styles.iconButtonPressed,
+ ]}
+ accessibilityRole="button"
+ accessibilityLabel="Refresh files"
+ >
+
+
+
+
+
+ {currentSortLabel}
+
- ) : null}
-
- {shouldShowInlinePreview ? (
-
-
-
-
-
-
-
- [
- styles.iconButton,
- (hovered || pressed) && styles.iconButtonHovered,
- pressed && styles.iconButtonPressed,
- ]}
- accessibilityRole="button"
- accessibilityLabel="Refresh files"
- >
-
-
-
-
-
- {currentSortLabel}
-
-
-
- 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}
- />
- {
- treeListRef.current?.scrollToOffset({
- offset: nextOffset,
- animated: false,
- });
- }}
- />
-
- ) : (
-
-
-
-
- [
- styles.iconButton,
- (hovered || pressed) && styles.iconButtonHovered,
- pressed && styles.iconButtonPressed,
- ]}
- accessibilityRole="button"
- accessibilityLabel="Refresh files"
- >
-
-
-
-
-
- {currentSortLabel}
-
-
-
- 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}
- />
- {
- treeListRef.current?.scrollToOffset({
- offset: nextOffset,
- animated: false,
- });
- }}
- />
-
- )}
+
+ 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}
+ />
+ {
+ treeListRef.current?.scrollToOffset({
+ offset: nextOffset,
+ animated: false,
+ });
+ }}
+ />
)}
-
- {isMobile ? (
-
-
-
- {selectedEntryPath?.split("/").pop() ?? "Preview"}
-
-
-
-
-
-
-
- ) : null}
);
}
-function FilePreviewBody({
- preview,
- isLoading,
- variant,
- showDesktopWebScrollbar,
-}: {
- preview: ExplorerFile | null;
- isLoading: boolean;
- variant: "inline" | "sheet";
- showDesktopWebScrollbar: boolean;
-}) {
- const enablePreviewDesktopScrollbar =
- variant === "inline" && showDesktopWebScrollbar;
- const previewScrollRef = useRef(null);
- const previewScrollbarMetrics = useWebDesktopScrollbarMetrics();
-
- const handlePreviewScroll = useCallback(
- (event: NativeSyntheticEvent) => {
- if (enablePreviewDesktopScrollbar) {
- previewScrollbarMetrics.onScroll(event);
- }
- },
- [enablePreviewDesktopScrollbar, previewScrollbarMetrics]
- );
-
- const handlePreviewLayout = useCallback(
- (event: LayoutChangeEvent) => {
- if (enablePreviewDesktopScrollbar) {
- previewScrollbarMetrics.onLayout(event);
- }
- },
- [enablePreviewDesktopScrollbar, previewScrollbarMetrics]
- );
-
- if (isLoading && !preview) {
- return (
-
-
- Loading file…
-
- );
- }
-
- if (!preview) {
- return (
-
- No preview available
-
- );
- }
-
- if (preview.kind === "text") {
- if (variant === "sheet") {
- return (
-
-
- {preview.content}
-
-
- );
- }
- return (
-
-
-
- {preview.content}
-
-
- {
- previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
- }}
- />
-
- );
- }
-
- if (preview.kind === "image" && preview.content) {
- if (variant === "sheet") {
- return (
-
-
-
- );
- }
- return (
-
-
-
-
- {
- previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
- }}
- />
-
- );
- }
-
- return (
-
- Binary preview unavailable
- {formatFileSize({ size: preview.size })}
-
- );
-}
-
-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([
diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx
new file mode 100644
index 000000000..ff699d036
--- /dev/null
+++ b/packages/app/src/components/file-pane.tsx
@@ -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(null);
+ const previewScrollbarMetrics = useWebDesktopScrollbarMetrics();
+
+ const handlePreviewScroll = useCallback(
+ (event: NativeSyntheticEvent) => {
+ if (enablePreviewDesktopScrollbar) {
+ previewScrollbarMetrics.onScroll(event);
+ }
+ },
+ [enablePreviewDesktopScrollbar, previewScrollbarMetrics]
+ );
+
+ const handlePreviewLayout = useCallback(
+ (event: LayoutChangeEvent) => {
+ if (enablePreviewDesktopScrollbar) {
+ previewScrollbarMetrics.onLayout(event);
+ }
+ },
+ [enablePreviewDesktopScrollbar, previewScrollbarMetrics]
+ );
+
+ if (isLoading && !preview) {
+ return (
+
+
+ Loading file…
+
+ );
+ }
+
+ if (!preview) {
+ return (
+
+ No preview available
+
+ );
+ }
+
+ if (preview.kind === "text") {
+ return (
+
+
+
+ {preview.content}
+
+
+ {
+ previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
+ }}
+ />
+
+ );
+ }
+
+ if (preview.kind === "image" && preview.content) {
+ return (
+
+
+
+
+ {
+ previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
+ }}
+ />
+
+ );
+ }
+
+ return (
+
+ Binary preview unavailable
+ {formatFileSize({ size: preview.size })}
+
+ );
+}
+
+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 (
+
+ {query.data?.error ? (
+
+ {query.data.error}
+
+ ) : null}
+
+
+
+ );
+}
+
+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,
+ },
+}));
diff --git a/packages/app/src/components/sortable-inline-list.web.tsx b/packages/app/src/components/sortable-inline-list.web.tsx
index 8405a61be..68ba549af 100644
--- a/packages/app/src/components/sortable-inline-list.web.tsx
+++ b/packages/app/src/components/sortable-inline-list.web.tsx
@@ -57,7 +57,10 @@ function SortableItem({
// 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(" ");
diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx
index 9ddf65442..05b23b396 100644
--- a/packages/app/src/components/terminal-emulator.tsx
+++ b/packages/app/src/components/terminal-emulator.tsx
@@ -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",
diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx
index a7a2bff13..b10ed4c84 100644
--- a/packages/app/src/components/terminal-pane.tsx
+++ b/packages/app/src/components/terminal-pane.tsx
@@ -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({
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) {
diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx
index 0b16faf97..e767025e0 100644
--- a/packages/app/src/screens/workspace/workspace-screen.tsx
+++ b/packages/app/src/screens/workspace/workspace-screen.tsx
@@ -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(() => {
- 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();
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(() => {
+ 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(null);
const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState(
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 (
+
+ );
+ }
+
return (
;
}
+ if (activeDescriptor.kind === "file") {
+ return ;
+ }
+
const tabAgent = agentsById.get(activeDescriptor.agentId) ?? null;
const tabAgentStatusBucket = tabAgent
? deriveSidebarStateBucket({
@@ -1416,6 +1591,8 @@ function WorkspaceScreenContent({
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({
) : (
-
-
+
+
@@ -1527,6 +1704,8 @@ function WorkspaceScreenContent({
/>
) : null}
+ ) : tab.kind === "file" ? (
+
) : (
);
@@ -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({
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({
/>
) : (
-
-
+
@@ -1759,6 +1954,7 @@ function WorkspaceScreenContent({
workspaceId={normalizedWorkspaceId}
workspaceRoot={normalizedWorkspaceId}
isGit={isGitCheckout}
+ onOpenFile={handleOpenFileFromExplorer}
/>
@@ -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,
diff --git a/packages/app/src/stores/workspace-file-tabs-store.ts b/packages/app/src/stores/workspace-file-tabs-store.ts
new file mode 100644
index 000000000..e8955d777
--- /dev/null
+++ b/packages/app/src/stores/workspace-file-tabs-store.ts
@@ -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;
+ 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()(
+ (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);
+ },
+ })
+);
+
diff --git a/packages/app/src/stores/workspace-tabs-store.ts b/packages/app/src/stores/workspace-tabs-store.ts
index 6c443d4db..8b0c7e841 100644
--- a/packages/app/src/stores/workspace-tabs-store.ts
+++ b/packages/app/src/stores/workspace-tabs-store.ts
@@ -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();
@@ -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;
}
diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts
index b5fa9a93d..0f0663198 100644
--- a/packages/app/src/styles/theme.ts
+++ b/packages/app/src/styles/theme.ts
@@ -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 = {
diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts
index dfe09ed24..9f596aa97 100644
--- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts
+++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts
@@ -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 | 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;
diff --git a/packages/app/src/terminal/runtime/terminal-output-delivery-queue.test.ts b/packages/app/src/terminal/runtime/terminal-output-delivery-queue.test.ts
index d901e7e4c..f709349bc 100644
--- a/packages/app/src/terminal/runtime/terminal-output-delivery-queue.test.ts
+++ b/packages/app/src/terminal/runtime/terminal-output-delivery-queue.test.ts
@@ -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 },
]);
});
});
diff --git a/packages/app/src/terminal/runtime/terminal-output-delivery-queue.ts b/packages/app/src/terminal/runtime/terminal-output-delivery-queue.ts
index c6f89ab0e..a3d477973 100644
--- a/packages/app/src/terminal/runtime/terminal-output-delivery-queue.ts
+++ b/packages/app/src/terminal/runtime/terminal-output-delivery-queue.ts
@@ -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,
});
}
diff --git a/packages/app/src/terminal/runtime/terminal-output-pump.test.ts b/packages/app/src/terminal/runtime/terminal-output-pump.test.ts
index 76c14cdfd..510fc6272 100644
--- a/packages/app/src/terminal/runtime/terminal-output-pump.test.ts
+++ b/packages/app/src/terminal/runtime/terminal-output-pump.test.ts
@@ -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("");
diff --git a/packages/app/src/terminal/runtime/terminal-output-pump.ts b/packages/app/src/terminal/runtime/terminal-output-pump.ts
index 40ccb54ac..f1834005c 100644
--- a/packages/app/src/terminal/runtime/terminal-output-pump.ts
+++ b/packages/app/src/terminal/runtime/terminal-output-pump.ts
@@ -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 | 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,
});
}
diff --git a/packages/app/src/terminal/runtime/terminal-stream-controller.ts b/packages/app/src/terminal/runtime/terminal-stream-controller.ts
index 9ef9e3a5f..d768a2c6d 100644
--- a/packages/app/src/terminal/runtime/terminal-stream-controller.ts
+++ b/packages/app/src/terminal/runtime/terminal-stream-controller.ts
@@ -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 {
diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts
index 205e8bdfc..b0dd0dfb2 100644
--- a/packages/app/src/utils/host-routes.test.ts
+++ b/packages/app/src/utils/host-routes.test.ts
@@ -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")}`
+ );
+ });
});
diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts
index 48224363f..3cab853ed 100644
--- a/packages/app/src/utils/host-routes.ts
+++ b/packages/app/src/utils/host-routes.ts
@@ -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,
diff --git a/packages/app/src/utils/to-xterm-theme.ts b/packages/app/src/utils/to-xterm-theme.ts
new file mode 100644
index 000000000..3c9907250
--- /dev/null
+++ b/packages/app/src/utils/to-xterm-theme.ts
@@ -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,
+ };
+}
+
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index 5f2ce5ee1..ef2b1552c 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -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"
}
}