diff --git a/src/handlers/filesystem-handlers.ts b/src/handlers/filesystem-handlers.ts index 83ce9aad..2e143228 100644 --- a/src/handlers/filesystem-handlers.ts +++ b/src/handlers/filesystem-handlers.ts @@ -7,6 +7,7 @@ import { moveFile, getFileInfo, writePdf, + getDefaultEditorMetadata, type FileResult, type MultiFileResult } from '../tools/filesystem.js'; @@ -137,6 +138,8 @@ export async function handleReadFile(args: unknown): Promise { fileName: path.basename(resolvedFilePath), filePath: resolvedFilePath, fileType: 'unsupported' as const, + sourceTool: 'read_file', + ...await getDefaultEditorMetadata(resolvedFilePath), content: pdfContent .filter((item): item is { type: "text"; text: string } => item.type === "text") .map((item) => item.text) @@ -164,6 +167,8 @@ export async function handleReadFile(args: unknown): Promise { fileName: path.basename(resolvedFilePath), filePath: resolvedFilePath, fileType: 'image', + sourceTool: 'read_file', + ...await getDefaultEditorMetadata(resolvedFilePath), content: imageData, imageData, mimeType: fileResult.mimeType @@ -183,6 +188,8 @@ export async function handleReadFile(args: unknown): Promise { fileName: path.basename(resolvedFilePath), filePath: resolvedFilePath, fileType, + sourceTool: 'read_file', + ...await getDefaultEditorMetadata(resolvedFilePath), content: textContent, }, }; @@ -303,6 +310,8 @@ export async function handleWriteFile(args: unknown): Promise { fileName: path.basename(resolvedWritePath), filePath: resolvedWritePath, fileType: resolvePreviewFileType(resolvedWritePath), + sourceTool: 'write_file', + ...await getDefaultEditorMetadata(resolvedWritePath), }, }; } catch (error) { diff --git a/src/tools/edit.ts b/src/tools/edit.ts index 489bf6df..9ad08085 100644 --- a/src/tools/edit.ts +++ b/src/tools/edit.ts @@ -15,7 +15,7 @@ * 3. Unify the editRange() signature to handle both text search/replace and structured edits */ -import { readFile, writeFile, readFileInternal, validatePath } from './filesystem.js'; +import { getDefaultEditorMetadata, readFile, writeFile, readFileInternal, validatePath } from './filesystem.js'; import fs from 'fs/promises'; import { ServerResult } from '../types.js'; import { recursiveFuzzyIndexOf, getSimilarityRatio } from './fuzzySearch.js'; @@ -227,6 +227,8 @@ RECOMMENDATION: For large search/replace operations, consider breaking them into fileName: path.basename(resolvedEditPath), filePath: resolvedEditPath, fileType: resolvePreviewFileType(resolvedEditPath), + sourceTool: 'edit_block', + ...await getDefaultEditorMetadata(resolvedEditPath), }, }; } @@ -438,6 +440,8 @@ export async function handleEditBlock(args: unknown): Promise { fileName: path.basename(resolvedRangePath), filePath: resolvedRangePath, fileType: resolvePreviewFileType(resolvedRangePath), + sourceTool: 'edit_block', + ...await getDefaultEditorMetadata(resolvedRangePath), }, }; } catch (error) { @@ -476,6 +480,8 @@ export async function handleEditBlock(args: unknown): Promise { fileName: path.basename(resolvedEditRangePath), filePath: resolvedEditRangePath, fileType: resolvePreviewFileType(resolvedEditRangePath), + sourceTool: 'edit_block', + ...await getDefaultEditorMetadata(resolvedEditRangePath), }, }; } @@ -492,4 +498,4 @@ export async function handleEditBlock(args: unknown): Promise { search: parsed.old_string, replace: parsed.new_string }, parsed.expected_replacements); -} \ No newline at end of file +} diff --git a/src/tools/filesystem.ts b/src/tools/filesystem.ts index 19666705..51b3a6ba 100644 --- a/src/tools/filesystem.ts +++ b/src/tools/filesystem.ts @@ -2,6 +2,8 @@ import fs from "fs/promises"; import path from "path"; import os from 'os'; import fetch from 'cross-fetch'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; import { capture } from '../utils/capture.js'; import { withTimeout } from '../utils/withTimeout.js'; import { configManager } from '../config-manager.js'; @@ -1041,4 +1043,54 @@ export async function writePdf( } else { throw new Error('Invalid content type for writePdf. Expected string (markdown) or array of operations.'); } -} \ No newline at end of file +} + +const execFileAsync = promisify(execFile); +type DefaultEditorMetadata = { defaultEditorName?: string; defaultEditorPath?: string }; +type DefaultEditorCacheEntry = { metadata: DefaultEditorMetadata; expiresAt?: number }; +const DEFAULT_EDITOR_NEGATIVE_CACHE_MS = 5 * 60 * 1000; +const defaultEditorCache = new Map(); + +function escapeAppleScriptString(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +export async function getDefaultEditorMetadata(filePath: string): Promise { + if (os.platform() !== 'darwin') { + return {}; + } + + let cacheKey = ''; + try { + const extension = path.extname(filePath).toLowerCase(); + cacheKey = extension || path.basename(filePath).toLowerCase(); + const cached = defaultEditorCache.get(cacheKey); + if (cached) { + if (!cached.expiresAt || cached.expiresAt > Date.now()) { + return cached.metadata; + } + defaultEditorCache.delete(cacheKey); + } + + const script = `set appAlias to default application of (info for POSIX file "${escapeAppleScriptString(filePath)}")\nreturn (name of (info for appAlias)) & linefeed & POSIX path of appAlias`; + const { stdout } = await execFileAsync('osascript', ['-e', script], { timeout: 12000 }); + const lines = stdout.split('\n').map((line) => line.trim()).filter(Boolean); + const defaultEditorName = lines[lines.length - 2]?.replace(/\.app$/i, '') ?? ''; + const defaultEditorPath = lines[lines.length - 1] ?? ''; + + if (defaultEditorName && defaultEditorPath.startsWith('/')) { + const metadata = { defaultEditorName, defaultEditorPath }; + defaultEditorCache.set(cacheKey, { metadata }); + return metadata; + } + + defaultEditorCache.set(cacheKey, { metadata: {}, expiresAt: Date.now() + DEFAULT_EDITOR_NEGATIVE_CACHE_MS }); + } catch { + if (cacheKey) { + defaultEditorCache.set(cacheKey, { metadata: {}, expiresAt: Date.now() + DEFAULT_EDITOR_NEGATIVE_CACHE_MS }); + } + // Generic UI fallback is good enough if detection fails. + } + + return {}; +} diff --git a/src/types.ts b/src/types.ts index 6d5bf5cd..61a50e61 100644 --- a/src/types.ts +++ b/src/types.ts @@ -76,6 +76,9 @@ export interface FilePreviewStructuredContent { fileName: string; filePath: string; fileType: PreviewFileType; + sourceTool?: 'read_file' | 'write_file' | 'edit_block'; + defaultEditorName?: string; + defaultEditorPath?: string; content?: string; imageData?: string; mimeType?: string; diff --git a/src/ui/file-preview/src/app.ts b/src/ui/file-preview/src/app.ts index 89b98402..ec11c11c 100644 --- a/src/ui/file-preview/src/app.ts +++ b/src/ui/file-preview/src/app.ts @@ -11,7 +11,7 @@ import { attachDirectoryHandlers } from './directory-controller.js'; import { buildDocumentLayout } from './document-layout.js'; import { getDocumentFullscreenAvailability, parseReadRange, stripReadStatusLine } from './document-workspace.js'; import { getFileTypeCapabilities, renderPayloadBody } from './file-type-handlers.js'; -import { buildOpenInEditorCommand, buildOpenInFolderCommand, detectDefaultMarkdownEditor, renderMarkdownEditorAppIcon } from './host/external-actions.js'; +import { buildOpenInEditorCommand, buildOpenInFolderCommand, renderMarkdownEditorAppIcon } from './host/external-actions.js'; import { attachSelectionContext } from './host/selection-context.js'; import { createMarkdownController } from './markdown/controller.js'; import { @@ -47,7 +47,10 @@ let inlinePayloadBeforeFullscreen: RenderPayload | undefined; let directoryBackPayload: RenderPayload | undefined; let selectionAbortController: AbortController | null = null; const markdownEditorAppCache = new Map(); -const markdownEditorAppPending = new Set(); + +function getTelemetryToolName(payload: RenderPayload | undefined): string { + return typeof payload?.sourceTool === 'string' ? payload.sourceTool : 'read_file'; +} async function callToolIfReady(name: string, args: Record): Promise { return rpcCallTool ? rpcCallTool(name, args) : undefined; @@ -168,7 +171,10 @@ async function readAndResolvePayload( try { const freshPayload = await markdownController.readPayload(payload.filePath); if (freshPayload) { - onReady(freshPayload); + onReady({ + ...freshPayload, + sourceTool: payload.sourceTool ?? freshPayload.sourceTool, + }); if (freshPayload.fileType === 'markdown') { void markdownController.refreshFromDisk(freshPayload); } @@ -244,21 +250,16 @@ export function renderApp( availableDisplayModes: getAvailableDisplayModes(), }).canFullscreen; + if (payload.fileType === 'markdown' && payload.defaultEditorName) { + markdownEditorAppCache.set(payload.filePath, { + appName: payload.defaultEditorName, + appPath: payload.defaultEditorPath, + }); + } + const defaultMarkdownEditor = payload.fileType === 'markdown' ? markdownEditorAppCache.get(payload.filePath) : undefined; - if (payload.fileType === 'markdown' && !defaultMarkdownEditor) { - void detectDefaultMarkdownEditor({ - filePath: payload.filePath, - editorAppCache: markdownEditorAppCache, - editorAppPending: markdownEditorAppPending, - callTool: callToolIfReady, - extractToolText, - onDetected: () => { - rerenderCurrent?.(); - }, - }); - } const layout = buildDocumentLayout({ payload, @@ -477,13 +478,16 @@ export function bootstrapApp(): void { const result = await app.requestDisplayMode({ mode }); return typeof result.mode === 'string' ? result.mode : null; }; - trackUiEvent = createUiEventTracker( + const filePreviewUiEvent = createUiEventTracker( (name, args) => app.callServerTool({ name, arguments: args }), { component: 'file_preview', - baseParams: { tool_name: 'read_file' }, } ); + trackUiEvent = (event, params = {}) => filePreviewUiEvent(event, { + tool_name: getTelemetryToolName(currentPayload ?? hostPayload), + ...params, + }); app.ontoolinput = (params) => { const requestedPath = typeof params.arguments?.path === 'string' ? params.arguments.path : undefined; diff --git a/src/ui/file-preview/src/host/external-actions.ts b/src/ui/file-preview/src/host/external-actions.ts index ff29c7bc..2df4ee68 100644 --- a/src/ui/file-preview/src/host/external-actions.ts +++ b/src/ui/file-preview/src/host/external-actions.ts @@ -65,53 +65,6 @@ export function buildOpenInEditorCommand( return `xdg-open ${shellQuote(trimmedPath)}`; } -export async function detectDefaultMarkdownEditor(options: { - filePath: string; - editorAppCache: Map; - editorAppPending: Set; - callTool?: (name: string, args: Record) => Promise; - extractToolText: (value: unknown) => string | undefined; - onDetected?: () => void; -}): Promise { - const trimmedPath = options.filePath.trim(); - if (!trimmedPath || options.editorAppCache.has(trimmedPath) || options.editorAppPending.has(trimmedPath)) { - return; - } - - const userAgent = navigator.userAgent.toLowerCase(); - if (!userAgent.includes('mac')) { - return; - } - - options.editorAppPending.add(trimmedPath); - try { - const detectCommand = `osascript -e ${shellQuote(`set appAlias to default application of (info for POSIX file "${trimmedPath.replace(/"/g, '\\"')}") -return (name of (info for appAlias)) & linefeed & POSIX path of appAlias`)}`; - const detectResult = await options.callTool?.('start_process', { - command: detectCommand, - timeout_ms: 12000, - }); - const text = options.extractToolText(detectResult) ?? ''; - if (!text || text.toLowerCase().includes('error') || text.toLowerCase().includes('execution')) { - return; - } - const lines = text.split('\n').map((line) => line.trim()).filter(Boolean); - const appName = lines[lines.length - 2]?.replace(/\.app$/i, '') ?? ''; - const appPath = lines[lines.length - 1] ?? ''; - if (appName && appPath.startsWith('/')) { - options.editorAppCache.set(trimmedPath, { - appName, - appPath, - }); - options.onDetected?.(); - } - } catch { - // Fall back to generic editor label. - } finally { - options.editorAppPending.delete(trimmedPath); - } -} - export function renderMarkdownEditorAppIcon(): string { return ''; } diff --git a/test/test-file-handlers.js b/test/test-file-handlers.js index 44070acc..a741fe58 100644 --- a/test/test-file-handlers.js +++ b/test/test-file-handlers.js @@ -18,7 +18,7 @@ import { fileURLToPath } from 'url'; import assert from 'assert'; import { readFile, writeFile, getFileInfo } from '../dist/tools/filesystem.js'; import { getFileHandler } from '../dist/utils/files/factory.js'; -import { handleReadFile } from '../dist/handlers/filesystem-handlers.js'; +import { handleReadFile, handleWriteFile } from '../dist/handlers/filesystem-handlers.js'; import { handleEditBlock } from '../dist/handlers/edit-search-handlers.js'; // Get directory name @@ -306,6 +306,7 @@ async function testReadFilePreviewMetadata() { assert.ok(markdownResult.structuredContent, 'Markdown should include structuredContent'); assert.strictEqual(markdownResult.structuredContent.fileType, 'markdown', 'Markdown fileType should be markdown'); assert.strictEqual(markdownResult.structuredContent.filePath, MD_FILE, 'Markdown file path should be present'); + assert.strictEqual(markdownResult.structuredContent.sourceTool, 'read_file', 'Markdown preview should preserve source tool'); assert.strictEqual(markdownResult.structuredContent.content, markdownResult.content[0].text, 'Markdown structuredContent should include returned content'); const textResult = await handleReadFile({ path: TEXT_FILE }); @@ -313,6 +314,7 @@ async function testReadFilePreviewMetadata() { assert.ok(textResult.content[0].text.includes(textContent), 'Legacy content should still include text body'); assert.ok(textResult.structuredContent, 'Text should include structuredContent'); assert.strictEqual(textResult.structuredContent.fileType, 'text', 'Text fileType should be text'); + assert.strictEqual(textResult.structuredContent.sourceTool, 'read_file', 'Text preview should preserve source tool'); assert.strictEqual(textResult.structuredContent.content, textResult.content[0].text, 'Text structuredContent should include returned content'); const htmlResult = await handleReadFile({ path: HTML_FILE }); @@ -320,6 +322,7 @@ async function testReadFilePreviewMetadata() { assert.ok(htmlResult.content[0].text.includes('

Preview

'), 'Legacy content should still include html body'); assert.ok(htmlResult.structuredContent, 'HTML should include structuredContent'); assert.strictEqual(htmlResult.structuredContent.fileType, 'html', 'HTML fileType should be html'); + assert.strictEqual(htmlResult.structuredContent.sourceTool, 'read_file', 'HTML preview should preserve source tool'); assert.strictEqual(htmlResult.structuredContent.content, htmlResult.content[0].text, 'HTML structuredContent should include returned content'); const imageResult = await handleReadFile({ path: IMAGE_FILE }); @@ -332,6 +335,7 @@ async function testReadFilePreviewMetadata() { assert.strictEqual(imageResult.structuredContent.content, imageResult.structuredContent.imageData, 'Image structuredContent should include file content'); assert.strictEqual(imageResult.structuredContent.mimeType, 'image/png', 'Image structured payload should include mimeType'); assert.strictEqual(imageResult.structuredContent.filePath, IMAGE_FILE, 'Image file path should be present'); + assert.strictEqual(imageResult.structuredContent.sourceTool, 'read_file', 'Image preview should preserve source tool'); const svgResult = await handleReadFile({ path: SVG_FILE }); assert.ok(Array.isArray(svgResult.content), 'SVG result should include content array'); @@ -341,6 +345,11 @@ async function testReadFilePreviewMetadata() { assert.strictEqual(svgResult.structuredContent.mimeType, 'image/svg+xml', 'SVG structured payload should include SVG mimeType'); assert.strictEqual(typeof svgResult.structuredContent.imageData, 'string', 'SVG structured payload should include imageData'); assert.ok(svgResult.structuredContent.imageData.length > 0, 'SVG structured payload should include non-empty imageData'); + assert.strictEqual(svgResult.structuredContent.sourceTool, 'read_file', 'SVG preview should preserve source tool'); + + const writeResult = await handleWriteFile({ path: TEXT_FILE, content: 'written through handler' }); + assert.ok(writeResult.structuredContent, 'write_file should include structuredContent'); + assert.strictEqual(writeResult.structuredContent.sourceTool, 'write_file', 'write_file preview should preserve source tool'); const nullArgsResult = await handleReadFile(null); assert.ok(Array.isArray(nullArgsResult.content), 'Null-args result should include content array'); @@ -375,6 +384,7 @@ async function testMarkdownExactMatchSave() { assert.strictEqual(result.content[0].type, 'text', 'edit_block result[0] should be text'); assert.ok(result.structuredContent, 'edit_block should return structuredContent'); assert.ok(result.structuredContent.filePath, 'edit_block structuredContent should include filePath'); + assert.strictEqual(result.structuredContent.sourceTool, 'edit_block', 'edit_block preview should preserve source tool'); assert.match( result.content[0].text, /\[Reading \d+ lines? from/,