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
@@ -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;
}
}