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:
@@ -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