Add read-only file explorer

This commit is contained in:
Mohamed Boudra
2025-11-07 20:36:23 +01:00
parent 30686df548
commit c5b30ed878
7 changed files with 876 additions and 2 deletions

View File

@@ -70,6 +70,7 @@ export default function RootLayout() {
<Stack.Screen name="settings" />
<Stack.Screen name="audio-test" />
<Stack.Screen name="git-diff" />
<Stack.Screen name="file-explorer" />
</Stack>
<GlobalFooter />
</AppContainer>

View File

@@ -13,7 +13,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import ReanimatedAnimated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { MoreVertical, GitBranch } from "lucide-react-native";
import { MoreVertical, GitBranch, Folder } from "lucide-react-native";
import { BackHeader } from "@/components/headers/back-header";
import { AgentStreamView } from "@/components/agent-stream-view";
import { AgentInputArea } from "@/components/agent-input-area";
@@ -185,6 +185,13 @@ export default function AgentScreen() {
}
}, [id, router, handleCloseMenu]);
const handleBrowseFiles = useCallback(() => {
handleCloseMenu();
if (id) {
router.push(`/file-explorer?agentId=${id}`);
}
}, [handleCloseMenu, id, router]);
if (!agent) {
return (
<View style={styles.container}>
@@ -257,6 +264,10 @@ export default function AgentScreen() {
<GitBranch size={20} color={theme.colors.foreground} />
<Text style={styles.menuItemText}>View Changes</Text>
</Pressable>
<Pressable onPress={handleBrowseFiles} style={styles.menuItem}>
<Folder size={20} color={theme.colors.foreground} />
<Text style={styles.menuItemText}>Browse Files</Text>
</Pressable>
</View>
</View>
</Modal>

View File

@@ -0,0 +1,360 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
Image,
Pressable,
ScrollView,
Text,
View,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useLocalSearchParams } from "expo-router";
import { BackHeader } from "@/components/headers/back-header";
import { useSession, type ExplorerEntry } from "@/contexts/session-context";
export default function FileExplorerScreen() {
const { agentId } = useLocalSearchParams<{ agentId: string }>();
const {
agents,
fileExplorer,
requestDirectoryListing,
requestFilePreview,
} = useSession();
const [currentPath, setCurrentPath] = useState(".");
const [selectedEntryPath, setSelectedEntryPath] = useState<string | null>(null);
const agent = agentId ? agents.get(agentId) : undefined;
const explorerState = agentId ? fileExplorer.get(agentId) : undefined;
const directory = explorerState?.directories.get(currentPath);
const entries = directory?.entries ?? [];
const isLoading = explorerState?.isLoading ?? false;
const error = explorerState?.lastError ?? null;
const preview = selectedEntryPath
? explorerState?.files.get(selectedEntryPath)
: null;
useEffect(() => {
if (!agentId) {
return;
}
setCurrentPath(".");
setSelectedEntryPath(null);
requestDirectoryListing(agentId, ".");
}, [agentId, requestDirectoryListing]);
const parentPath = useMemo(() => {
if (currentPath === ".") {
return null;
}
const segments = currentPath.split("/");
segments.pop();
const nextPath = segments.join("/");
return nextPath.length === 0 ? "." : nextPath;
}, [currentPath]);
const handleEntryPress = useCallback(
(entry: ExplorerEntry) => {
if (!agentId) {
return;
}
if (entry.kind === "directory") {
setCurrentPath(entry.path);
setSelectedEntryPath(null);
requestDirectoryListing(agentId, entry.path);
return;
}
setSelectedEntryPath(entry.path);
requestFilePreview(agentId, entry.path);
},
[agentId, requestDirectoryListing, requestFilePreview]
);
const handleNavigateUp = useCallback(() => {
if (!agentId || !parentPath) {
return;
}
setCurrentPath(parentPath);
setSelectedEntryPath(null);
requestDirectoryListing(agentId, parentPath);
}, [agentId, parentPath, requestDirectoryListing]);
if (!agent) {
return (
<View style={styles.container}>
<BackHeader title="Files" />
<View style={styles.centerState}>
<Text style={styles.errorText}>Agent not found</Text>
</View>
</View>
);
}
return (
<View style={styles.container}>
<BackHeader title="Files" />
<View style={styles.headerRow}>
<View style={styles.breadcrumbs}>
<Text style={styles.breadcrumbLabel}>Path</Text>
<ScrollView horizontal>
<Text style={styles.breadcrumbText}>{currentPath}</Text>
</ScrollView>
</View>
{parentPath && (
<Pressable style={styles.upButton} onPress={handleNavigateUp}>
<Text style={styles.upButtonText}>Up</Text>
</Pressable>
)}
</View>
<View style={styles.content}>
<View style={styles.listSection}>
{error ? (
<View style={styles.centerState}>
<Text style={styles.errorText}>{error}</Text>
</View>
) : isLoading && entries.length === 0 ? (
<View style={styles.centerState}>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading...</Text>
</View>
) : entries.length === 0 ? (
<View style={styles.centerState}>
<Text style={styles.emptyText}>Directory is empty</Text>
</View>
) : (
<ScrollView>
{entries.map((entry) => (
<Pressable
key={entry.path}
style={[
styles.entryRow,
entry.kind === "directory"
? styles.directoryRow
: styles.fileRow,
selectedEntryPath === entry.path && styles.selectedRow,
]}
onPress={() => handleEntryPress(entry)}
>
<View style={styles.entryTextContainer}>
<Text style={styles.entryName}>{entry.name}</Text>
<Text style={styles.entryMeta}>
{entry.kind.toUpperCase()} ·{" "}
{formatFileSize({ size: entry.size })} ·{" "}
{formatModifiedTime({ value: entry.modifiedAt })}
</Text>
</View>
<Text style={styles.entryAction}>
{entry.kind === "directory" ? "Open" : "Preview"}
</Text>
</Pressable>
))}
</ScrollView>
)}
</View>
<View style={styles.previewSection}>
{selectedEntryPath && isLoading && !preview ? (
<View style={styles.centerState}>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file...</Text>
</View>
) : !selectedEntryPath ? (
<View style={styles.centerState}>
<Text style={styles.emptyText}>Select a file to preview</Text>
</View>
) : !preview ? (
<View style={styles.centerState}>
<Text style={styles.emptyText}>No preview available yet</Text>
</View>
) : preview.kind === "text" ? (
<ScrollView
style={styles.textPreview}
horizontal={false}
contentContainerStyle={styles.textPreviewContent}
>
<ScrollView horizontal>
<Text style={styles.codeText}>{preview.content}</Text>
</ScrollView>
</ScrollView>
) : preview.kind === "image" && preview.content ? (
<View style={styles.imagePreviewContainer}>
<Image
source={{
uri: `data:${preview.mimeType ?? "image/png"};base64,${
preview.content
}`,
}}
style={styles.image}
resizeMode="contain"
/>
</View>
) : (
<View style={styles.centerState}>
<Text style={styles.emptyText}>Binary preview unavailable</Text>
<Text style={styles.entryMeta}>
{formatFileSize({ size: preview.size })}
</Text>
</View>
)}
</View>
</View>
</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`;
}
function formatModifiedTime({ value }: { value: string }): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString();
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.background,
},
headerRow: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
gap: theme.spacing[3],
},
breadcrumbs: {
flex: 1,
},
breadcrumbLabel: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
marginBottom: theme.spacing[1],
},
breadcrumbText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontFamily: "monospace",
},
upButton: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.muted,
},
upButtonText: {
color: theme.colors.foreground,
fontWeight: theme.fontWeight.semibold,
},
content: {
flex: 1,
flexDirection: "column",
paddingHorizontal: theme.spacing[4],
paddingBottom: theme.spacing[4],
gap: theme.spacing[4],
},
listSection: {
flex: 1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[2],
},
previewSection: {
flex: 1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[2],
},
centerState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
padding: theme.spacing[4],
},
loadingText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
errorText: {
color: theme.colors.destructive,
fontSize: theme.fontSize.base,
textAlign: "center",
},
emptyText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.base,
textAlign: "center",
},
entryRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
marginBottom: theme.spacing[2],
},
directoryRow: {
backgroundColor: theme.colors.muted,
},
fileRow: {
backgroundColor: theme.colors.card,
},
selectedRow: {
borderWidth: theme.borderWidth[2],
borderColor: theme.colors.primary,
},
entryTextContainer: {
flex: 1,
marginRight: theme.spacing[3],
},
entryName: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
},
entryMeta: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
entryAction: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
textPreview: {
flex: 1,
},
textPreviewContent: {
padding: theme.spacing[2],
},
codeText: {
color: theme.colors.foreground,
fontFamily: "monospace",
fontSize: theme.fontSize.sm,
},
imagePreviewContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
image: {
width: "100%",
height: "100%",
},
}));

View File

@@ -81,6 +81,54 @@ export interface Command {
exitCode: number | null;
}
export type ExplorerEntryKind = "file" | "directory";
export type ExplorerFileKind = "text" | "image" | "binary";
export type ExplorerEncoding = "utf-8" | "base64" | "none";
export interface ExplorerEntry {
name: string;
path: string;
kind: ExplorerEntryKind;
size: number;
modifiedAt: string;
}
export interface ExplorerFile {
path: string;
kind: ExplorerFileKind;
encoding: ExplorerEncoding;
content?: string;
mimeType?: string;
size: number;
modifiedAt: string;
}
interface ExplorerDirectory {
path: string;
entries: ExplorerEntry[];
}
interface ExplorerRequestState {
path: string;
mode: "list" | "file";
}
export interface AgentFileExplorerState {
directories: Map<string, ExplorerDirectory>;
files: Map<string, ExplorerFile>;
isLoading: boolean;
lastError: string | null;
pendingRequest: ExplorerRequestState | null;
}
const createExplorerState = (): AgentFileExplorerState => ({
directories: new Map(),
files: new Map(),
isLoading: false,
lastError: null,
pendingRequest: null,
});
interface SessionContextValue {
// WebSocket
@@ -119,6 +167,11 @@ interface SessionContextValue {
gitDiffs: Map<string, string>;
requestGitDiff: (agentId: string) => void;
// File explorer
fileExplorer: Map<string, AgentFileExplorerState>;
requestDirectoryListing: (agentId: string, path: string) => void;
requestFilePreview: (agentId: string, path: string) => void;
// Helpers
initializeAgent: (params: { agentId: string; requestId?: string }) => void;
sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise<void>;
@@ -172,6 +225,19 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
const [agentUpdates, setAgentUpdates] = useState<Map<string, AgentUpdate[]>>(new Map());
const [pendingPermissions, setPendingPermissions] = useState<Map<string, PendingPermission>>(new Map());
const [gitDiffs, setGitDiffs] = useState<Map<string, string>>(new Map());
const [fileExplorer, setFileExplorer] = useState<Map<string, AgentFileExplorerState>>(new Map());
const updateExplorerState = useCallback(
(agentId: string, updater: (state: AgentFileExplorerState) => AgentFileExplorerState) => {
setFileExplorer((prev) => {
const next = new Map(prev);
const current = next.get(agentId) ?? createExplorerState();
next.set(agentId, updater(current));
return next;
});
},
[]
);
// WebSocket message handlers
useEffect(() => {
@@ -613,6 +679,47 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
}
});
const unsubFileExplorer = ws.on("file_explorer_response", (message) => {
if (message.type !== "file_explorer_response") {
return;
}
const { agentId, directory, file, mode, error } = message.payload;
console.log(
"[Session] File explorer response for agent:",
agentId,
mode,
error ? `(error: ${error})` : ""
);
updateExplorerState(agentId, (state) => {
const nextState: AgentFileExplorerState = {
...state,
isLoading: false,
lastError: error ?? null,
pendingRequest: null,
directories: state.directories,
files: state.files,
};
if (!error) {
if (mode === "list" && directory) {
const directories = new Map(state.directories);
directories.set(directory.path, directory);
nextState.directories = directories;
}
if (mode === "file" && file) {
const files = new Map(state.files);
files.set(file.path, file);
nextState.files = files;
}
}
return nextState;
});
});
return () => {
unsubSessionState();
unsubAgentCreated();
@@ -626,8 +733,9 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
unsubChunk();
unsubTranscription();
unsubGitDiff();
unsubFileExplorer();
};
}, [ws, audioPlayer, setIsPlayingAudio]);
}, [ws, audioPlayer, setIsPlayingAudio, updateExplorerState]);
const initializeAgent = useCallback(({ agentId, requestId }: { agentId: string; requestId?: string }) => {
setInitializingAgents((prev) => {
@@ -808,6 +916,48 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
ws.send(msg);
}, [ws]);
const requestDirectoryListing = useCallback((agentId: string, path: string) => {
const normalizedPath = path && path.length > 0 ? path : ".";
updateExplorerState(agentId, (state) => ({
...state,
isLoading: true,
lastError: null,
pendingRequest: { path: normalizedPath, mode: "list" },
}));
const msg: WSInboundMessage = {
type: "session",
message: {
type: "file_explorer_request",
agentId,
path: normalizedPath,
mode: "list",
},
};
ws.send(msg);
}, [updateExplorerState, ws]);
const requestFilePreview = useCallback((agentId: string, path: string) => {
const normalizedPath = path && path.length > 0 ? path : ".";
updateExplorerState(agentId, (state) => ({
...state,
isLoading: true,
lastError: null,
pendingRequest: { path: normalizedPath, mode: "file" },
}));
const msg: WSInboundMessage = {
type: "session",
message: {
type: "file_explorer_request",
agentId,
path: normalizedPath,
mode: "file",
},
};
ws.send(msg);
}, [updateExplorerState, ws]);
const value: SessionContextValue = {
ws,
audioPlayer,
@@ -831,6 +981,9 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
setPendingPermissions,
gitDiffs,
requestGitDiff,
fileExplorer,
requestDirectoryListing,
requestFilePreview,
initializeAgent,
sendAgentMessage,
sendAgentAudio,

View File

@@ -0,0 +1,209 @@
import { promises as fs } from "fs";
import path from "path";
export type ExplorerEntryKind = "file" | "directory";
export type ExplorerFileKind = "text" | "image" | "binary";
export type ExplorerEncoding = "utf-8" | "base64" | "none";
export interface ListDirectoryParams {
root: string;
relativePath?: string;
}
export interface ReadFileParams {
root: string;
relativePath: string;
}
export interface FileExplorerEntry {
name: string;
path: string;
kind: ExplorerEntryKind;
size: number;
modifiedAt: string;
}
export interface FileExplorerDirectory {
path: string;
entries: FileExplorerEntry[];
}
export interface FileExplorerFile {
path: string;
kind: ExplorerFileKind;
encoding: ExplorerEncoding;
content?: string;
mimeType?: string;
size: number;
modifiedAt: string;
}
const TEXT_EXTENSIONS = new Set([
".ts",
".tsx",
".js",
".jsx",
".json",
".md",
".txt",
".yml",
".yaml",
".css",
".scss",
".html",
".mjs",
".cjs",
".sh",
]);
const IMAGE_MIME_TYPES: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
};
interface ScopedPathParams {
root: string;
relativePath?: string;
}
interface EntryPayloadParams {
root: string;
targetPath: string;
name: string;
kind: ExplorerEntryKind;
}
export async function listDirectoryEntries({
root,
relativePath = ".",
}: ListDirectoryParams): Promise<FileExplorerDirectory> {
const directoryPath = await resolveScopedPath({ root, relativePath });
const stats = await fs.stat(directoryPath);
if (!stats.isDirectory()) {
throw new Error("Requested path is not a directory");
}
const dirents = await fs.readdir(directoryPath, { withFileTypes: true });
const entries = await Promise.all(
dirents.map(async (dirent) => {
const targetPath = path.join(directoryPath, dirent.name);
const kind: ExplorerEntryKind = dirent.isDirectory()
? "directory"
: "file";
return buildEntryPayload({ root, targetPath, name: dirent.name, kind });
})
);
entries.sort((a, b) => {
if (a.kind !== b.kind) {
return a.kind === "directory" ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
return {
path: normalizeRelativePath({ root, targetPath: directoryPath }),
entries,
};
}
export async function readExplorerFile({
root,
relativePath,
}: ReadFileParams): Promise<FileExplorerFile> {
const filePath = await resolveScopedPath({ root, relativePath });
const stats = await fs.stat(filePath);
if (!stats.isFile()) {
throw new Error("Requested path is not a file");
}
const ext = path.extname(filePath).toLowerCase();
const basePayload = {
path: normalizeRelativePath({ root, targetPath: filePath }),
size: stats.size,
modifiedAt: stats.mtime.toISOString(),
};
if (TEXT_EXTENSIONS.has(ext)) {
const content = await fs.readFile(filePath, "utf-8");
return {
...basePayload,
kind: "text",
encoding: "utf-8",
content,
mimeType: "text/plain",
};
}
if (ext in IMAGE_MIME_TYPES) {
const buffer = await fs.readFile(filePath);
return {
...basePayload,
kind: "image",
encoding: "base64",
content: buffer.toString("base64"),
mimeType: IMAGE_MIME_TYPES[ext],
};
}
return {
...basePayload,
kind: "binary",
encoding: "none",
mimeType: "application/octet-stream",
};
}
async function resolveScopedPath({
root,
relativePath = ".",
}: ScopedPathParams): Promise<string> {
const normalizedRoot = path.resolve(root);
const requestedPath = path.resolve(normalizedRoot, relativePath);
const relative = path.relative(normalizedRoot, requestedPath);
if (
relative === "" ||
(!relative.startsWith("..") && !path.isAbsolute(relative))
) {
return requestedPath;
}
throw new Error("Access outside of agent workspace is not allowed");
}
async function buildEntryPayload({
root,
targetPath,
name,
kind,
}: EntryPayloadParams): Promise<FileExplorerEntry> {
const stats = await fs.stat(targetPath);
return {
name,
path: normalizeRelativePath({ root, targetPath }),
kind,
size: stats.size,
modifiedAt: stats.mtime.toISOString(),
};
}
function normalizeRelativePath({
root,
targetPath,
}: {
root: string;
targetPath: string;
}): string {
const normalizedRoot = path.resolve(root);
const normalizedTarget = path.resolve(targetPath);
const relative = path.relative(normalizedRoot, normalizedTarget);
return relative === "" ? "." : relative.split(path.sep).join("/");
}

View File

@@ -127,6 +127,36 @@ export const GitDiffRequestSchema = z.object({
agentId: z.string(),
});
const FileExplorerEntrySchema = z.object({
name: z.string(),
path: z.string(),
kind: z.enum(["file", "directory"]),
size: z.number(),
modifiedAt: z.string(),
});
const FileExplorerFileSchema = z.object({
path: z.string(),
kind: z.enum(["text", "image", "binary"]),
encoding: z.enum(["utf-8", "base64", "none"]),
content: z.string().optional(),
mimeType: z.string().optional(),
size: z.number(),
modifiedAt: z.string(),
});
const FileExplorerDirectorySchema = z.object({
path: z.string(),
entries: z.array(FileExplorerEntrySchema),
});
export const FileExplorerRequestSchema = z.object({
type: z.literal("file_explorer_request"),
agentId: z.string(),
path: z.string().optional(),
mode: z.enum(["list", "file"]),
});
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
UserTextMessageSchema,
RealtimeAudioChunkMessageSchema,
@@ -143,6 +173,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
SetAgentModeMessageSchema,
AgentPermissionResponseMessageSchema,
GitDiffRequestSchema,
FileExplorerRequestSchema,
]);
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
@@ -336,6 +367,18 @@ export const GitDiffResponseSchema = z.object({
}),
});
export const FileExplorerResponseSchema = z.object({
type: z.literal("file_explorer_response"),
payload: z.object({
agentId: z.string(),
path: z.string(),
mode: z.enum(["list", "file"]),
directory: FileExplorerDirectorySchema.nullable(),
file: FileExplorerFileSchema.nullable(),
error: z.string().nullable(),
}),
});
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ActivityLogMessageSchema,
AssistantChunkMessageSchema,
@@ -354,6 +397,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
AgentPermissionRequestMessageSchema,
AgentPermissionResolvedMessageSchema,
GitDiffResponseSchema,
FileExplorerResponseSchema,
]);
export type SessionOutboundMessage = z.infer<
@@ -392,6 +436,8 @@ export type SetAgentModeMessage = z.infer<typeof SetAgentModeMessageSchema>;
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>;
export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>;
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
// ============================================================================
// WebSocket Level Messages (wraps session messages)

View File

@@ -13,6 +13,7 @@ import {
import type {
SessionInboundMessage,
SessionOutboundMessage,
FileExplorerRequest,
} from "./messages.js";
import { getSystemPrompt } from "./agent/system-prompt.js";
import { getAllTools } from "./agent/llm-openai.js";
@@ -35,6 +36,10 @@ import {
isTitleGeneratorInitialized,
} from "../services/agent-title-generator.js";
import { expandTilde } from "./terminal-mcp/tmux.js";
import {
listDirectoryEntries,
readExplorerFile,
} from "./file-explorer/service.js";
const execAsync = promisify(exec);
@@ -522,6 +527,10 @@ export class Session {
case "git_diff_request":
await this.handleGitDiffRequest(msg.agentId);
break;
case "file_explorer_request":
await this.handleFileExplorerRequest(msg);
break;
}
} catch (error: any) {
console.error(
@@ -1076,6 +1085,91 @@ export class Session {
}
}
/**
* Handle read-only file explorer requests scoped to an agent's cwd
*/
private async handleFileExplorerRequest(
request: FileExplorerRequest
): Promise<void> {
const { agentId, path: requestedPath = ".", mode } = request;
console.log(
`[Session ${this.clientId}] Handling file explorer request for agent ${agentId} (${mode} ${requestedPath})`
);
try {
const agents = this.agentManager.listAgents();
const agent = agents.find((a) => a.id === agentId);
if (!agent) {
this.emit({
type: "file_explorer_response",
payload: {
agentId,
path: requestedPath,
mode,
directory: null,
file: null,
error: `Agent not found: ${agentId}`,
},
});
return;
}
if (mode === "list") {
const directory = await listDirectoryEntries({
root: agent.cwd,
relativePath: requestedPath,
});
this.emit({
type: "file_explorer_response",
payload: {
agentId,
path: directory.path,
mode,
directory,
file: null,
error: null,
},
});
} else {
const file = await readExplorerFile({
root: agent.cwd,
relativePath: requestedPath,
});
this.emit({
type: "file_explorer_response",
payload: {
agentId,
path: file.path,
mode,
directory: null,
file,
error: null,
},
});
}
} catch (error: any) {
console.error(
`[Session ${this.clientId}] Failed to fulfill file explorer request for agent ${agentId}:`,
error
);
this.emit({
type: "file_explorer_response",
payload: {
agentId,
path: requestedPath,
mode,
directory: null,
file: null,
error: error.message,
},
});
}
}
/**
* Send current session state (live agents and commands) to client
*/