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(() => {
|
const baseRenderModel = useMemo(() => {
|
||||||
return buildAgentStreamRenderModel({
|
return buildAgentStreamRenderModel({
|
||||||
tail: streamItems,
|
tail: streamItems,
|
||||||
@@ -485,6 +492,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
cwd={agent.cwd}
|
cwd={agent.cwd}
|
||||||
metadata={data.metadata}
|
metadata={data.metadata}
|
||||||
isLastInSequence={isLastInSequence}
|
isLastInSequence={isLastInSequence}
|
||||||
|
onOpenFilePath={handleToolCallOpenFile}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -499,10 +507,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
result={data.result}
|
result={data.result}
|
||||||
status={data.status}
|
status={data.status}
|
||||||
isLastInSequence={isLastInSequence}
|
isLastInSequence={isLastInSequence}
|
||||||
|
onOpenFilePath={handleToolCallOpenFile}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[agent.cwd, streamRenderStrategy, setInlineDetailsExpanded],
|
[agent.cwd, streamRenderStrategy, setInlineDetailsExpanded, handleToolCallOpenFile],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderStreamItemContent = useCallback(
|
const renderStreamItemContent = useCallback(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Image,
|
Image,
|
||||||
Pressable,
|
Pressable,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
type GestureResponderEvent,
|
||||||
type LayoutChangeEvent,
|
type LayoutChangeEvent,
|
||||||
StyleProp,
|
StyleProp,
|
||||||
ViewStyle,
|
ViewStyle,
|
||||||
@@ -45,6 +46,7 @@ import {
|
|||||||
TriangleAlertIcon,
|
TriangleAlertIcon,
|
||||||
Scissors,
|
Scissors,
|
||||||
MicVocal,
|
MicVocal,
|
||||||
|
FileSymlink,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
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 type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||||
import { buildToolCallDisplayModel } from "@/utils/tool-call-display";
|
import { buildToolCallDisplayModel } from "@/utils/tool-call-display";
|
||||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||||
|
import { extractToolCallFilePath } from "@/utils/extract-tool-call-file-path";
|
||||||
import {
|
import {
|
||||||
hasMeaningfulToolCallDetail,
|
hasMeaningfulToolCallDetail,
|
||||||
isPendingToolCallDetail,
|
isPendingToolCallDetail,
|
||||||
@@ -1016,6 +1019,12 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
|||||||
marginLeft: theme.spacing[1],
|
marginLeft: theme.spacing[1],
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
|
openFileButton: {
|
||||||
|
marginLeft: theme.spacing[1],
|
||||||
|
padding: theme.spacing[1],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
chevronExpanded: {
|
chevronExpanded: {
|
||||||
transform: [{ rotate: "90deg" }],
|
transform: [{ rotate: "90deg" }],
|
||||||
},
|
},
|
||||||
@@ -2031,6 +2040,7 @@ interface ExpandableBadgeProps {
|
|||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
style?: StyleProp<ViewStyle>;
|
style?: StyleProp<ViewStyle>;
|
||||||
onToggle?: () => void;
|
onToggle?: () => void;
|
||||||
|
onOpenFile?: () => void;
|
||||||
onDetailHoverChange?: (hovered: boolean) => void;
|
onDetailHoverChange?: (hovered: boolean) => void;
|
||||||
renderDetails?: () => ReactNode;
|
renderDetails?: () => ReactNode;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
@@ -2320,6 +2330,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
icon,
|
icon,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
onOpenFile,
|
||||||
onDetailHoverChange,
|
onDetailHoverChange,
|
||||||
renderDetails,
|
renderDetails,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -2331,6 +2342,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const [isOpenFileHovered, setIsOpenFileHovered] = useState(false);
|
||||||
const [isPressed, setIsPressed] = useState(false);
|
const [isPressed, setIsPressed] = useState(false);
|
||||||
const isInteractive = Boolean(onToggle);
|
const isInteractive = Boolean(onToggle);
|
||||||
const hasDetailContent = Boolean(renderDetails);
|
const hasDetailContent = Boolean(renderDetails);
|
||||||
@@ -2350,6 +2362,15 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
() => onDetailHoverChange?.(false),
|
() => onDetailHoverChange?.(false),
|
||||||
[onDetailHoverChange],
|
[onDetailHoverChange],
|
||||||
);
|
);
|
||||||
|
const handleOpenFilePress = useCallback(
|
||||||
|
(event: GestureResponderEvent) => {
|
||||||
|
event.stopPropagation?.();
|
||||||
|
onOpenFile?.();
|
||||||
|
},
|
||||||
|
[onOpenFile],
|
||||||
|
);
|
||||||
|
const handleOpenFileHoverIn = useCallback(() => setIsOpenFileHovered(true), []);
|
||||||
|
const handleOpenFileHoverOut = useCallback(() => setIsOpenFileHovered(false), []);
|
||||||
|
|
||||||
const nativeGradientIdRef = useRef(
|
const nativeGradientIdRef = useRef(
|
||||||
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`,
|
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`,
|
||||||
@@ -2550,8 +2571,6 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
const pressHandlers = isInteractive
|
const pressHandlers = isInteractive
|
||||||
? {
|
? {
|
||||||
onPress: onToggle,
|
onPress: onToggle,
|
||||||
onHoverIn: handleHoverIn,
|
|
||||||
onHoverOut: handleHoverOut,
|
|
||||||
onPressIn: handlePressIn,
|
onPressIn: handlePressIn,
|
||||||
onPressOut: handlePressOut,
|
onPressOut: handlePressOut,
|
||||||
accessibilityRole: "button" as const,
|
accessibilityRole: "button" as const,
|
||||||
@@ -2559,7 +2578,12 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
: {};
|
: {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={containerStyle} testID={testID}>
|
<View
|
||||||
|
style={containerStyle}
|
||||||
|
testID={testID}
|
||||||
|
onPointerEnter={isWeb ? handleHoverIn : undefined}
|
||||||
|
onPointerLeave={isWeb ? handleHoverOut : undefined}
|
||||||
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
{...pressHandlers}
|
{...pressHandlers}
|
||||||
disabled={!isInteractive}
|
disabled={!isInteractive}
|
||||||
@@ -2588,6 +2612,23 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
onLabelLayout={handleLabelLayout}
|
onLabelLayout={handleLabelLayout}
|
||||||
onSecondaryLayout={handleSecondaryLayout}
|
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 ? (
|
{isInteractive && isHovered ? (
|
||||||
<ChevronRight size={14} color={theme.colors.foreground} style={chevronStyle} />
|
<ChevronRight size={14} color={theme.colors.foreground} style={chevronStyle} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -2619,6 +2660,7 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa
|
|||||||
if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false;
|
if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false;
|
||||||
if (previous.testID !== next.testID) return false;
|
if (previous.testID !== next.testID) return false;
|
||||||
if (previous.onToggle !== next.onToggle) 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.onDetailHoverChange !== next.onDetailHoverChange) return false;
|
||||||
if (previous.renderDetails !== next.renderDetails) return false;
|
if (previous.renderDetails !== next.renderDetails) return false;
|
||||||
return true;
|
return true;
|
||||||
@@ -2637,6 +2679,7 @@ interface ToolCallProps {
|
|||||||
disableOuterSpacing?: boolean;
|
disableOuterSpacing?: boolean;
|
||||||
onInlineDetailsHoverChange?: (hovered: boolean) => void;
|
onInlineDetailsHoverChange?: (hovered: boolean) => void;
|
||||||
onInlineDetailsExpandedChange?: (expanded: boolean) => void;
|
onInlineDetailsExpandedChange?: (expanded: boolean) => void;
|
||||||
|
onOpenFilePath?: (filePath: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ToolCall = memo(function ToolCall({
|
export const ToolCall = memo(function ToolCall({
|
||||||
@@ -2652,6 +2695,7 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
disableOuterSpacing,
|
disableOuterSpacing,
|
||||||
onInlineDetailsHoverChange,
|
onInlineDetailsHoverChange,
|
||||||
onInlineDetailsExpandedChange,
|
onInlineDetailsExpandedChange,
|
||||||
|
onOpenFilePath,
|
||||||
}: ToolCallProps) {
|
}: ToolCallProps) {
|
||||||
const { openToolCall } = useToolCallSheet();
|
const { openToolCall } = useToolCallSheet();
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
@@ -2710,6 +2754,17 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
const hasDetails = Boolean(error) || hasMeaningfulToolCallDetail(effectiveDetail);
|
const hasDetails = Boolean(error) || hasMeaningfulToolCallDetail(effectiveDetail);
|
||||||
const canOpenDetails = hasDetails || isLoadingDetails;
|
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(() => {
|
const handleToggle = useCallback(() => {
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
openToolCall({
|
openToolCall({
|
||||||
@@ -2792,6 +2847,7 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
icon={IconComponent}
|
icon={IconComponent}
|
||||||
isExpanded={!isMobile && isExpanded}
|
isExpanded={!isMobile && isExpanded}
|
||||||
onToggle={canOpenDetails ? handleToggle : undefined}
|
onToggle={canOpenDetails ? handleToggle : undefined}
|
||||||
|
onOpenFile={handleOpenFile}
|
||||||
renderDetails={canOpenDetails && !isMobile ? renderDetails : undefined}
|
renderDetails={canOpenDetails && !isMobile ? renderDetails : undefined}
|
||||||
isLoading={status === "running" || status === "executing"}
|
isLoading={status === "running" || status === "executing"}
|
||||||
isError={status === "failed"}
|
isError={status === "failed"}
|
||||||
@@ -2813,5 +2869,6 @@ function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) {
|
|||||||
if (previous.metadata !== next.metadata) return false;
|
if (previous.metadata !== next.metadata) return false;
|
||||||
if (previous.isLastInSequence !== next.isLastInSequence) return false;
|
if (previous.isLastInSequence !== next.isLastInSequence) return false;
|
||||||
if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false;
|
if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false;
|
||||||
|
if (previous.onOpenFilePath !== next.onOpenFilePath) return false;
|
||||||
return true;
|
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