diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 9766d130f..eefae3570 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -271,6 +271,13 @@ const AgentStreamViewComponent = forwardRef { + handleInlinePathPress({ raw: filePath, path: filePath }); + }, + [handleInlinePathPress], + ); + const baseRenderModel = useMemo(() => { return buildAgentStreamRenderModel({ tail: streamItems, @@ -485,6 +492,7 @@ const AgentStreamViewComponent = forwardRef ); } @@ -499,10 +507,11 @@ const AgentStreamViewComponent = forwardRef ); }, - [agent.cwd, streamRenderStrategy, setInlineDetailsExpanded], + [agent.cwd, streamRenderStrategy, setInlineDetailsExpanded, handleToolCallOpenFile], ); const renderStreamItemContent = useCallback( diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index f6178fca8..0e1a47ac0 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -4,6 +4,7 @@ import { Image, Pressable, ActivityIndicator, + type GestureResponderEvent, type LayoutChangeEvent, StyleProp, ViewStyle, @@ -45,6 +46,7 @@ import { TriangleAlertIcon, Scissors, MicVocal, + FileSymlink, } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -64,6 +66,7 @@ import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream"; import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; import { buildToolCallDisplayModel } from "@/utils/tool-call-display"; import { resolveToolCallIcon } from "@/utils/tool-call-icon"; +import { extractToolCallFilePath } from "@/utils/extract-tool-call-file-path"; import { hasMeaningfulToolCallDetail, isPendingToolCallDetail, @@ -1016,6 +1019,12 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ marginLeft: theme.spacing[1], flexShrink: 0, }, + openFileButton: { + marginLeft: theme.spacing[1], + padding: theme.spacing[1], + borderRadius: theme.borderRadius.md, + flexShrink: 0, + }, chevronExpanded: { transform: [{ rotate: "90deg" }], }, @@ -2031,6 +2040,7 @@ interface ExpandableBadgeProps { isExpanded: boolean; style?: StyleProp; onToggle?: () => void; + onOpenFile?: () => void; onDetailHoverChange?: (hovered: boolean) => void; renderDetails?: () => ReactNode; isLoading?: boolean; @@ -2320,6 +2330,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ icon, isExpanded, onToggle, + onOpenFile, onDetailHoverChange, renderDetails, isLoading = false, @@ -2331,6 +2342,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ const { theme } = useUnistyles(); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const [isHovered, setIsHovered] = useState(false); + const [isOpenFileHovered, setIsOpenFileHovered] = useState(false); const [isPressed, setIsPressed] = useState(false); const isInteractive = Boolean(onToggle); const hasDetailContent = Boolean(renderDetails); @@ -2350,6 +2362,15 @@ const ExpandableBadge = memo(function ExpandableBadge({ () => onDetailHoverChange?.(false), [onDetailHoverChange], ); + const handleOpenFilePress = useCallback( + (event: GestureResponderEvent) => { + event.stopPropagation?.(); + onOpenFile?.(); + }, + [onOpenFile], + ); + const handleOpenFileHoverIn = useCallback(() => setIsOpenFileHovered(true), []); + const handleOpenFileHoverOut = useCallback(() => setIsOpenFileHovered(false), []); const nativeGradientIdRef = useRef( `shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`, @@ -2550,8 +2571,6 @@ const ExpandableBadge = memo(function ExpandableBadge({ const pressHandlers = isInteractive ? { onPress: onToggle, - onHoverIn: handleHoverIn, - onHoverOut: handleHoverOut, onPressIn: handlePressIn, onPressOut: handlePressOut, accessibilityRole: "button" as const, @@ -2559,7 +2578,12 @@ const ExpandableBadge = memo(function ExpandableBadge({ : {}; return ( - + + {onOpenFile && isHovered ? ( + + + + ) : null} {isInteractive && isHovered ? ( ) : null} @@ -2619,6 +2660,7 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false; if (previous.testID !== next.testID) return false; if (previous.onToggle !== next.onToggle) return false; + if (previous.onOpenFile !== next.onOpenFile) return false; if (previous.onDetailHoverChange !== next.onDetailHoverChange) return false; if (previous.renderDetails !== next.renderDetails) return false; return true; @@ -2637,6 +2679,7 @@ interface ToolCallProps { disableOuterSpacing?: boolean; onInlineDetailsHoverChange?: (hovered: boolean) => void; onInlineDetailsExpandedChange?: (expanded: boolean) => void; + onOpenFilePath?: (filePath: string) => void; } export const ToolCall = memo(function ToolCall({ @@ -2652,6 +2695,7 @@ export const ToolCall = memo(function ToolCall({ disableOuterSpacing, onInlineDetailsHoverChange, onInlineDetailsExpandedChange, + onOpenFilePath, }: ToolCallProps) { const { openToolCall } = useToolCallSheet(); const [isExpanded, setIsExpanded] = useState(false); @@ -2710,6 +2754,17 @@ export const ToolCall = memo(function ToolCall({ const hasDetails = Boolean(error) || hasMeaningfulToolCallDetail(effectiveDetail); const canOpenDetails = hasDetails || isLoadingDetails; + const extractedFilePath = useMemo( + () => extractToolCallFilePath(effectiveDetail), + [effectiveDetail], + ); + const handleOpenFile = useMemo(() => { + if (!extractedFilePath || !onOpenFilePath) { + return undefined; + } + return () => onOpenFilePath(extractedFilePath); + }, [extractedFilePath, onOpenFilePath]); + const handleToggle = useCallback(() => { if (isMobile) { openToolCall({ @@ -2792,6 +2847,7 @@ export const ToolCall = memo(function ToolCall({ icon={IconComponent} isExpanded={!isMobile && isExpanded} onToggle={canOpenDetails ? handleToggle : undefined} + onOpenFile={handleOpenFile} renderDetails={canOpenDetails && !isMobile ? renderDetails : undefined} isLoading={status === "running" || status === "executing"} isError={status === "failed"} @@ -2813,5 +2869,6 @@ function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) { if (previous.metadata !== next.metadata) return false; if (previous.isLastInSequence !== next.isLastInSequence) return false; if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false; + if (previous.onOpenFilePath !== next.onOpenFilePath) return false; return true; } diff --git a/packages/app/src/utils/extract-tool-call-file-path.test.ts b/packages/app/src/utils/extract-tool-call-file-path.test.ts new file mode 100644 index 000000000..beefc52c3 --- /dev/null +++ b/packages/app/src/utils/extract-tool-call-file-path.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { extractToolCallFilePath } from "./extract-tool-call-file-path"; + +describe("extractToolCallFilePath", () => { + it("returns filePath for read/edit/write", () => { + expect(extractToolCallFilePath({ type: "read", filePath: "/a.ts" })).toBe("/a.ts"); + expect(extractToolCallFilePath({ type: "edit", filePath: "/b.ts" })).toBe("/b.ts"); + expect(extractToolCallFilePath({ type: "write", filePath: "/c.ts" })).toBe("/c.ts"); + }); + + it("returns null for empty filePath", () => { + expect(extractToolCallFilePath({ type: "read", filePath: "" })).toBeNull(); + }); + + it.each([ + ["cat ~/file.md", "~/file.md"], + ["wc -l ~/.paseo/plans/projects-settings-page.md", "~/.paseo/plans/projects-settings-page.md"], + ["head -n 20 src/index.ts", "src/index.ts"], + ["tail -f /var/log/x.log", "/var/log/x.log"], + ["less ./README.md", "./README.md"], + ["stat /tmp/foo", "/tmp/foo"], + ])("matches shell command %s", (command, expected) => { + expect(extractToolCallFilePath({ type: "shell", command })).toBe(expected); + }); + + it.each([ + "cat a.ts | grep foo", + "cat a.ts > b.ts", + "cat a.ts b.ts", + "echo hi", + "ls /tmp", + "rm -rf /tmp", + ])("returns null for non-matching shell %s", (command) => { + expect(extractToolCallFilePath({ type: "shell", command })).toBeNull(); + }); + + it("returns null for unrelated detail types", () => { + expect(extractToolCallFilePath({ type: "search", query: "foo" })).toBeNull(); + expect(extractToolCallFilePath({ type: "unknown", input: null, output: null })).toBeNull(); + expect(extractToolCallFilePath(undefined)).toBeNull(); + }); +}); diff --git a/packages/app/src/utils/extract-tool-call-file-path.ts b/packages/app/src/utils/extract-tool-call-file-path.ts new file mode 100644 index 000000000..63b2aaff6 --- /dev/null +++ b/packages/app/src/utils/extract-tool-call-file-path.ts @@ -0,0 +1,70 @@ +import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; + +const SHELL_FILE_COMMANDS = new Set([ + "cat", + "bat", + "less", + "more", + "head", + "tail", + "wc", + "nl", + "tac", + "od", + "xxd", + "file", + "stat", + "column", + "md5", + "md5sum", + "sha1sum", + "sha256sum", + "shasum", +]); + +const SHELL_OPERATOR_PATTERN = /[|><&;`$()]/; +const SHORT_FLAG_PATTERN = /^-[a-zA-Z]$/; + +function extractFromShellCommand(command: string): string | null { + const trimmed = command.trim(); + if (!trimmed || SHELL_OPERATOR_PATTERN.test(trimmed)) { + return null; + } + const tokens = trimmed.split(/\s+/); + if (tokens.length < 2) { + return null; + } + if (!SHELL_FILE_COMMANDS.has(tokens[0])) { + return null; + } + const last = tokens[tokens.length - 1]; + if (last.startsWith("-")) { + return null; + } + if (tokens.length > 2) { + const prev = tokens[tokens.length - 2]; + if (!prev.startsWith("-")) { + const prevPrev = tokens[tokens.length - 3]; + if (!prevPrev || !SHORT_FLAG_PATTERN.test(prevPrev)) { + return null; + } + } + } + return last; +} + +export function extractToolCallFilePath(detail: ToolCallDetail | undefined): string | null { + if (!detail) { + return null; + } + switch (detail.type) { + case "read": + case "edit": + case "write": + return detail.filePath || null; + case "shell": + return extractFromShellCommand(detail.command); + default: + return null; + } +}