feat: CLI v2.1.68–v2.1.74 compatibility updates #221

Merged
naomi merged 20 commits from feat/cli into main 2026-03-13 01:34:45 -07:00
3 changed files with 442 additions and 5 deletions
Showing only changes of commit 02c8d6c990 - Show all commits
+39 -5
View File
@@ -2,8 +2,9 @@
import { marked } from "marked";
import hljs from "highlight.js";
import { onMount } from "svelte";
import { openUrl } from "@tauri-apps/plugin-opener";
import { openUrl, openPath } from "@tauri-apps/plugin-opener";
import { clipboardStore } from "$lib/stores/clipboard";
import { linkifyFilePaths } from "$lib/utils/filePaths";
interface Props {
content: string;
@@ -113,7 +114,8 @@
let parsedHtml = $derived.by(() => {
try {
const html = marked.parse(content) as string;
return processSpoilers(html);
const withSpoilers = processSpoilers(html);
return linkifyFilePaths(withSpoilers);
} catch {
return content;
}
@@ -140,9 +142,18 @@
function handleLinkClick(event: MouseEvent) {
const target = event.target as HTMLElement;
const anchor = target.closest("a");
if (anchor?.href) {
event.preventDefault();
openUrl(anchor.href);
if (!anchor) return;
event.preventDefault();
const filePath = anchor.dataset.filepath;
if (filePath) {
void openPath(filePath);
return;
}
if (anchor.href) {
void openUrl(anchor.href);
}
}
@@ -453,4 +464,27 @@
border-radius: 2px;
padding: 0 2px;
}
.markdown-content :global(.file-link) {
display: inline-flex;
align-items: center;
gap: 0.25em;
color: var(--accent-primary, #f472b6);
text-decoration: none;
border: 1px solid color-mix(in srgb, var(--accent-primary) 30%, transparent);
background: color-mix(in srgb, var(--accent-primary) 8%, transparent);
border-radius: 4px;
padding: 0.1em 0.4em;
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 0.875em;
cursor: pointer;
transition: all 0.15s ease;
word-break: break-all;
}
.markdown-content :global(.file-link:hover) {
background: color-mix(in srgb, var(--accent-primary) 18%, transparent);
border-color: color-mix(in srgb, var(--accent-primary) 60%, transparent);
color: var(--accent-secondary, #e879f9);
}
</style>
+270
View File
@@ -0,0 +1,270 @@
import { describe, it, expect } from "vitest";
import {
BINARY_FILE_EXTENSIONS,
getFileExtension,
getFileTypeIcon,
isBinaryFilePath,
linkifyFilePaths,
} from "./filePaths";
describe("getFileExtension", () => {
it("returns the lowercase extension of a simple path", () => {
expect(getFileExtension("/tmp/report.pdf")).toBe("pdf");
});
it("returns the lowercase extension for uppercase file names", () => {
expect(getFileExtension("/tmp/AUDIO.MP3")).toBe("mp3");
});
it("returns the extension for a path with multiple dots", () => {
expect(getFileExtension("/tmp/my.file.docx")).toBe("docx");
});
it("returns an empty string when there is no extension", () => {
expect(getFileExtension("/tmp/noextension")).toBe("");
});
it("returns an empty string for an empty string input", () => {
expect(getFileExtension("")).toBe("");
});
it("returns the extension for a home-relative path", () => {
expect(getFileExtension("~/downloads/track.wav")).toBe("wav");
});
});
describe("getFileTypeIcon", () => {
it("returns the PDF icon for .pdf files", () => {
expect(getFileTypeIcon("/tmp/doc.pdf")).toBe("📄");
});
it("returns the Word icon for .docx files", () => {
expect(getFileTypeIcon("/tmp/report.docx")).toBe("📝");
});
it("returns the Word icon for .doc files", () => {
expect(getFileTypeIcon("/tmp/old.doc")).toBe("📝");
});
it("returns the spreadsheet icon for .xlsx files", () => {
expect(getFileTypeIcon("/tmp/data.xlsx")).toBe("📊");
});
it("returns the spreadsheet icon for .xls files", () => {
expect(getFileTypeIcon("/tmp/data.xls")).toBe("📊");
});
it("returns the presentation icon for .pptx files", () => {
expect(getFileTypeIcon("/tmp/slides.pptx")).toBe("📽️");
});
it("returns the presentation icon for .ppt files", () => {
expect(getFileTypeIcon("/tmp/slides.ppt")).toBe("📽️");
});
it("returns the audio icon for .mp3 files", () => {
expect(getFileTypeIcon("/tmp/song.mp3")).toBe("🎵");
});
it("returns the audio icon for .wav files", () => {
expect(getFileTypeIcon("/tmp/sound.wav")).toBe("🎵");
});
it("returns the audio icon for .ogg files", () => {
expect(getFileTypeIcon("/tmp/audio.ogg")).toBe("🎵");
});
it("returns the audio icon for .flac files", () => {
expect(getFileTypeIcon("/tmp/lossless.flac")).toBe("🎵");
});
it("returns the audio icon for .aac files", () => {
expect(getFileTypeIcon("/tmp/compressed.aac")).toBe("🎵");
});
it("returns the audio icon for .m4a files", () => {
expect(getFileTypeIcon("/tmp/itunes.m4a")).toBe("🎵");
});
it("returns the video icon for .mp4 files", () => {
expect(getFileTypeIcon("/tmp/video.mp4")).toBe("🎬");
});
it("returns the video icon for .avi files", () => {
expect(getFileTypeIcon("/tmp/old.avi")).toBe("🎬");
});
it("returns the video icon for .mov files", () => {
expect(getFileTypeIcon("/tmp/clip.mov")).toBe("🎬");
});
it("returns the video icon for .mkv files", () => {
expect(getFileTypeIcon("/tmp/film.mkv")).toBe("🎬");
});
it("returns the video icon for .webm files", () => {
expect(getFileTypeIcon("/tmp/stream.webm")).toBe("🎬");
});
it("returns the archive icon for .zip files", () => {
expect(getFileTypeIcon("/tmp/bundle.zip")).toBe("📦");
});
it("returns the archive icon for .tar files", () => {
expect(getFileTypeIcon("/tmp/archive.tar")).toBe("📦");
});
it("returns the archive icon for .gz files", () => {
expect(getFileTypeIcon("/tmp/compressed.gz")).toBe("📦");
});
it("returns the disk icon for .bin files", () => {
expect(getFileTypeIcon("/tmp/firmware.bin")).toBe("💿");
});
it("returns the disk icon for .iso files", () => {
expect(getFileTypeIcon("/tmp/image.iso")).toBe("💿");
});
it("returns the generic folder icon for an unknown extension", () => {
expect(getFileTypeIcon("/tmp/file.unknown")).toBe("📁");
});
it("returns the generic folder icon for a file with no extension", () => {
expect(getFileTypeIcon("/tmp/noext")).toBe("📁");
});
});
describe("isBinaryFilePath", () => {
it("returns true for a PDF path", () => {
expect(isBinaryFilePath("/tmp/report.pdf")).toBe(true);
});
it("returns true for an audio path", () => {
expect(isBinaryFilePath("/tmp/song.mp3")).toBe(true);
});
it("returns true for a video path", () => {
expect(isBinaryFilePath("/tmp/clip.mp4")).toBe(true);
});
it("returns true for a document path", () => {
expect(isBinaryFilePath("/tmp/doc.docx")).toBe(true);
});
it("returns false for a TypeScript file", () => {
expect(isBinaryFilePath("/src/index.ts")).toBe(false);
});
it("returns false for a text file", () => {
expect(isBinaryFilePath("/tmp/output.txt")).toBe(false);
});
it("returns false for a path with no extension", () => {
expect(isBinaryFilePath("/tmp/file")).toBe(false);
});
});
describe("BINARY_FILE_EXTENSIONS", () => {
it("includes pdf", () => {
expect(BINARY_FILE_EXTENSIONS).toContain("pdf");
});
it("includes common audio extensions", () => {
expect(BINARY_FILE_EXTENSIONS).toContain("mp3");
expect(BINARY_FILE_EXTENSIONS).toContain("wav");
});
it("includes common video extensions", () => {
expect(BINARY_FILE_EXTENSIONS).toContain("mp4");
});
it("includes common document extensions", () => {
expect(BINARY_FILE_EXTENSIONS).toContain("docx");
expect(BINARY_FILE_EXTENSIONS).toContain("xlsx");
});
});
describe("linkifyFilePaths", () => {
it("converts a PDF path in plain text to a file link", () => {
const html = "<p>Saved to /tmp/report.pdf successfully.</p>";
const result = linkifyFilePaths(html);
expect(result).toContain('data-filepath="/tmp/report.pdf"');
expect(result).toContain("📄");
expect(result).toContain('class="file-link"');
});
it("converts an audio path to a file link", () => {
const html = "<p>Audio saved to /tmp/output.mp3</p>";
const result = linkifyFilePaths(html);
expect(result).toContain('data-filepath="/tmp/output.mp3"');
expect(result).toContain("🎵");
});
it("does not linkify paths inside code blocks", () => {
const html = "<p>Example:</p><pre><code>/tmp/file.pdf</code></pre>";
const result = linkifyFilePaths(html);
expect(result).not.toContain('data-filepath="/tmp/file.pdf"');
expect(result).toContain("/tmp/file.pdf");
});
it("does not linkify paths inside inline code", () => {
const html = "<p>Use <code>/tmp/file.pdf</code> to open it.</p>";
const result = linkifyFilePaths(html);
expect(result).not.toContain('data-filepath="/tmp/file.pdf"');
expect(result).toContain("/tmp/file.pdf");
});
it("does not modify HTML that has no binary file paths", () => {
const html = "<p>Hello, this is regular text with /tmp/script.sh</p>";
const result = linkifyFilePaths(html);
expect(result).toBe(html);
});
it("does not linkify text file paths", () => {
const html = "<p>Saved to /tmp/output.txt</p>";
const result = linkifyFilePaths(html);
expect(result).not.toContain("data-filepath");
});
it("handles a home-relative path", () => {
const html = "<p>Saved to ~/downloads/audio.flac</p>";
const result = linkifyFilePaths(html);
expect(result).toContain('data-filepath="~/downloads/audio.flac"');
expect(result).toContain("🎵");
});
it("handles multiple file paths in the same HTML", () => {
const html = "<p>Files: /tmp/a.pdf and /tmp/b.mp3</p>";
const result = linkifyFilePaths(html);
expect(result).toContain('data-filepath="/tmp/a.pdf"');
expect(result).toContain('data-filepath="/tmp/b.mp3"');
});
it("does not linkify paths that contain double quotes (invalid path character)", () => {
// Double quotes are excluded from path chars so the path is not matched
const html = `<p>Saved to /tmp/my"file.pdf</p>`;
const result = linkifyFilePaths(html);
expect(result).not.toContain("data-filepath");
});
it("preserves existing HTML tags and attributes", () => {
const html = '<p class="foo">Saved to /tmp/report.pdf</p>';
const result = linkifyFilePaths(html);
expect(result).toContain('class="foo"');
expect(result).toContain('data-filepath="/tmp/report.pdf"');
});
it("does not double-linkify a path already inside an anchor tag", () => {
const html = '<a href="/tmp/file.pdf">/tmp/file.pdf</a>';
const result = linkifyFilePaths(html);
// The href is inside a tag (placeholder), the text content IS linkified
// but the href itself should not be modified
const hrefMatches = result.match(/href="[^"]*\/tmp\/file\.pdf[^"]*"/g) ?? [];
expect(hrefMatches.length).toBe(1);
});
it("returns the input unchanged when html is empty", () => {
expect(linkifyFilePaths("")).toBe("");
});
});
+133
View File
@@ -0,0 +1,133 @@
/**
* Utility functions for detecting and rendering binary file paths
* saved to disk by MCP tools via the Claude Code CLI.
*/
export const BINARY_FILE_EXTENSIONS = [
// Documents
"pdf",
"docx",
"doc",
"xlsx",
"xls",
"pptx",
"ppt",
// Audio
"mp3",
"wav",
"ogg",
"flac",
"aac",
"m4a",
// Video
"mp4",
"avi",
"mov",
"mkv",
"webm",
// Archives
"zip",
"tar",
"gz",
// Other binaries
"bin",
"iso",
] as const;
export type BinaryFileExtension = (typeof BINARY_FILE_EXTENSIONS)[number];
export function getFileExtension(filePath: string): string {
const lastDot = filePath.lastIndexOf(".");
if (lastDot === -1) return "";
return filePath.slice(lastDot + 1).toLowerCase();
}
export function getFileTypeIcon(filePath: string): string {
const ext = getFileExtension(filePath);
switch (ext) {
case "pdf":
return "📄";
case "docx":
case "doc":
return "📝";
case "xlsx":
case "xls":
return "📊";
case "pptx":
case "ppt":
return "📽️";
case "mp3":
case "wav":
case "ogg":
case "flac":
case "aac":
case "m4a":
return "🎵";
case "mp4":
case "avi":
case "mov":
case "mkv":
case "webm":
return "🎬";
case "zip":
case "tar":
case "gz":
return "📦";
case "bin":
case "iso":
return "💿";
default:
return "📁";
}
}
export function isBinaryFilePath(filePath: string): boolean {
const ext = getFileExtension(filePath);
return (BINARY_FILE_EXTENSIONS as readonly string[]).includes(ext);
}
/**
* Post-processes HTML content to convert binary file paths into clickable
* anchor elements with file-type icons. Skips content inside code blocks
* and existing HTML tags so it doesn't double-linkify or corrupt attributes.
*/
export function linkifyFilePaths(html: string): string {
const codeBlockPlaceholders: string[] = [];
// Temporarily replace code blocks and inline code with placeholders
let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => {
codeBlockPlaceholders.push(match);
return `__FILEPATH_CODE_${codeBlockPlaceholders.length - 1}__`;
});
// Temporarily replace all HTML tags with placeholders
const tagPlaceholders: string[] = [];
processed = processed.replace(/<[^>]+>/g, (match) => {
tagPlaceholders.push(match);
return `__FILEPATH_TAG_${tagPlaceholders.length - 1}__`;
});
// Now replace binary file paths in the remaining plain text
const extensions = BINARY_FILE_EXTENSIONS.join("|");
// No lookahead needed — the greedy character class naturally backtracks to the
// shortest match ending with a recognised extension, terminating before any
// character excluded by the class (spaces, HTML-unsafe chars, tag placeholders).
const filePathRegex = new RegExp(`((?:~/|/)[^\\s<>"'\`]+\\.(?:${extensions}))`, "gi");
processed = processed.replace(filePathRegex, (_, filePath: string) => {
const icon = getFileTypeIcon(filePath);
const escaped = filePath.replace(/"/g, "&quot;");
return `<a class="file-link" href="#" data-filepath="${escaped}">${icon} ${filePath}</a>`;
});
// Restore HTML tags
processed = processed.replace(/__FILEPATH_TAG_(\d+)__/g, (_, index) => {
return tagPlaceholders[parseInt(index, 10)];
});
// Restore code blocks
processed = processed.replace(/__FILEPATH_CODE_(\d+)__/g, (_, index) => {
return codeBlockPlaceholders[parseInt(index, 10)];
});
return processed;
}