feat: add open-file button to tool call badges
Show a file-symlink icon on hover that opens the file referenced by read, edit, write, and simple shell commands (cat, head, tail, etc). Extracts the file path from tool call details via a new utility with tests.
This commit is contained in:
@@ -271,6 +271,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
],
|
||||
);
|
||||
|
||||
const handleToolCallOpenFile = useCallback(
|
||||
(filePath: string) => {
|
||||
handleInlinePathPress({ raw: filePath, path: filePath });
|
||||
},
|
||||
[handleInlinePathPress],
|
||||
);
|
||||
|
||||
const baseRenderModel = useMemo(() => {
|
||||
return buildAgentStreamRenderModel({
|
||||
tail: streamItems,
|
||||
@@ -485,6 +492,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
cwd={agent.cwd}
|
||||
metadata={data.metadata}
|
||||
isLastInSequence={isLastInSequence}
|
||||
onOpenFilePath={handleToolCallOpenFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -499,10 +507,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
result={data.result}
|
||||
status={data.status}
|
||||
isLastInSequence={isLastInSequence}
|
||||
onOpenFilePath={handleToolCallOpenFile}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[agent.cwd, streamRenderStrategy, setInlineDetailsExpanded],
|
||||
[agent.cwd, streamRenderStrategy, setInlineDetailsExpanded, handleToolCallOpenFile],
|
||||
);
|
||||
|
||||
const renderStreamItemContent = useCallback(
|
||||
|
||||
@@ -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<ViewStyle>;
|
||||
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 (
|
||||
<View style={containerStyle} testID={testID}>
|
||||
<View
|
||||
style={containerStyle}
|
||||
testID={testID}
|
||||
onPointerEnter={isWeb ? handleHoverIn : undefined}
|
||||
onPointerLeave={isWeb ? handleHoverOut : undefined}
|
||||
>
|
||||
<Pressable
|
||||
{...pressHandlers}
|
||||
disabled={!isInteractive}
|
||||
@@ -2588,6 +2612,23 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
onLabelLayout={handleLabelLayout}
|
||||
onSecondaryLayout={handleSecondaryLayout}
|
||||
/>
|
||||
{onOpenFile && isHovered ? (
|
||||
<Pressable
|
||||
onPress={handleOpenFilePress}
|
||||
onHoverIn={handleOpenFileHoverIn}
|
||||
onHoverOut={handleOpenFileHoverOut}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Open file"
|
||||
testID="tool-call-open-file"
|
||||
style={expandableBadgeStylesheet.openFileButton}
|
||||
hitSlop={6}
|
||||
>
|
||||
<FileSymlink
|
||||
size={14}
|
||||
color={isOpenFileHovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{isInteractive && isHovered ? (
|
||||
<ChevronRight size={14} color={theme.colors.foreground} style={chevronStyle} />
|
||||
) : 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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user