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:
Mohamed Boudra
2026-04-26 08:15:16 +07:00
parent 1ec448d2b9
commit 521944f19b
4 changed files with 182 additions and 4 deletions
@@ -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(
+60 -3
View File
@@ -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;
}
}