Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 57 additions & 52 deletions src/handlers/filesystem-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,29 +134,48 @@ export async function handleReadFile(args: unknown): Promise<ServerResult> {
},
...pdfContent
],
structuredContent: {
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)
.join("\n"),
},
...(parsed.origin === 'ui' ? {
structuredContent: {
fileName: path.basename(resolvedFilePath),
filePath: resolvedFilePath,
fileType: 'unsupported' as const,
...await getDefaultEditorMetadata(resolvedFilePath),
},
} : {}),
};
}

// Handle image files
if (fileResult.metadata?.isImage) {
// Return the image bytes in the MCP content array so the host model can
// actually see the image. structuredContent additionally carries the bytes
// for the preview widget to render.
// actually see the image. The preview widget gets its copy from its own
// origin:'ui' read — structuredContent stays metadata-only.
const imageData = typeof fileResult.content === 'string'
? fileResult.content
: fileResult.content.toString('base64');
const imageSummary = `Image file: ${parsed.path} (${fileResult.mimeType})\n`;
const imageStructuredContent = {
fileName: path.basename(resolvedFilePath),
filePath: resolvedFilePath,
fileType: 'image' as const,
...await getDefaultEditorMetadata(resolvedFilePath),
mimeType: fileResult.mimeType,
};
// Widget pull (origin: 'ui'): return the base64 in a TEXT block, never
// an image block. An image content block makes the host inline-vision-
// render the result, which preempts delivery of this RPC response to
// the widget — leaving the preview stuck on "Preparing preview…" for
// exactly the host-rendered types (png/jpeg/gif/webp). Text-only lets
// the RPC response through so the widget can draw the <img>.
if (parsed.origin === 'ui') {
return {
content: [{ type: "text", text: imageData }],
structuredContent: imageStructuredContent,
};
}
// Model-facing read: keep the image block so the host/model sees it.
// No structuredContent — the widget renders from its own origin:'ui'
// read, and nothing else consumes it.
return {
content: [
{
Expand All @@ -169,36 +188,31 @@ export async function handleReadFile(args: unknown): Promise<ServerResult> {
mimeType: fileResult.mimeType
}
],
structuredContent: {
fileName: path.basename(resolvedFilePath),
filePath: resolvedFilePath,
fileType: 'image',
sourceTool: 'read_file',
...await getDefaultEditorMetadata(resolvedFilePath),
// Image bytes for the preview widget, carried once. Deduped from the
// old `content` + `imageData` pair down to this single `content` field.
content: imageData,
mimeType: fileResult.mimeType
}
};
} else {
// For all other files, return as text.
// structuredContent carries only file metadata (no content duplication);
// the widget reads text from the MCP content array.
const textContent = typeof fileResult.content === 'string'
// the widget reads text from the MCP content array (over the RPC read).
let textContent = typeof fileResult.content === 'string'
? fileResult.content
: fileResult.content.toString('utf8');
const fileType = fileResult.metadata?.isDirectory ? 'directory' as const : resolvePreviewFileType(resolvedFilePath);
// The directory fallback prefixes a "use list_directory instead" hint
// for the LLM. The widget's own read (a list_directory preview pulls
// read_file on the dir path) would render that hint as a notice — strip it.
if (parsed.origin === 'ui' && fileType === 'directory') {
textContent = textContent.replace(/^This is a directory, not a file\.[^\n]*\n+/, '');
}
return {
content: [{ type: "text", text: textContent }],
structuredContent: {
fileName: path.basename(resolvedFilePath),
filePath: resolvedFilePath,
fileType,
sourceTool: 'read_file',
...await getDefaultEditorMetadata(resolvedFilePath),
content: textContent,
},
...(parsed.origin === 'ui' ? {
structuredContent: {
fileName: path.basename(resolvedFilePath),
filePath: resolvedFilePath,
fileType,
...await getDefaultEditorMetadata(resolvedFilePath),
},
} : {}),
};
}
};
Expand Down Expand Up @@ -306,20 +320,14 @@ export async function handleWriteFile(args: unknown): Promise<ServerResult> {

// Provide more informative message based on mode
const modeMessage = parsed.mode === 'append' ? 'appended to' : 'wrote to';
const resolvedWritePath = resolveAbsolutePath(parsed.path);

// No structuredContent: nothing in the UI calls write_file (the widget
// previews the write via its own read_file pull at tool-result time).
return {
content: [{
type: "text",
text: `Successfully ${modeMessage} ${parsed.path} (${lineCount} lines) ${errorMessage}`
}],
structuredContent: {
fileName: path.basename(resolvedWritePath),
filePath: resolvedWritePath,
fileType: resolvePreviewFileType(resolvedWritePath),
sourceTool: 'write_file',
...await getDefaultEditorMetadata(resolvedWritePath),
},
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
Expand Down Expand Up @@ -358,16 +366,13 @@ export async function handleListDirectory(args: unknown): Promise<ServerResult>

return {
content: [{ type: "text", text: resultText }],
structuredContent: {
fileName: path.basename(resolvedPath),
filePath: resolvedPath,
fileType: 'directory' as const,
sourceTool: 'list_directory',
// Carry the listing in structuredContent too. Chat reads the text
// content array, but structuredContent-only consumers (e.g. Cowork)
// render from here and would otherwise show an empty directory.
content: resultText,
},
...(parsed.origin === 'ui' ? {
structuredContent: {
fileName: path.basename(resolvedPath),
filePath: resolvedPath,
fileType: 'directory' as const,
},
} : {}),
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
Expand Down
53 changes: 30 additions & 23 deletions src/tools/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ function getCharacterCodeData(expected: string, actual: string): {
};
}

export async function performSearchReplace(filePath: string, block: SearchReplace, expectedReplacements: number = 1): Promise<ServerResult> {
export async function performSearchReplace(filePath: string, block: SearchReplace, expectedReplacements: number = 1, origin?: 'ui' | 'llm'): Promise<ServerResult> {
// Get file extension for telemetry using path module
const fileExtension = path.extname(filePath).toLowerCase();

Expand Down Expand Up @@ -223,13 +223,18 @@ RECOMMENDATION: For large search/replace operations, consider breaking them into
type: "text",
text: `${statusLine}${previewContent}`
}],
structuredContent: {
fileName: path.basename(resolvedEditPath),
filePath: resolvedEditPath,
fileType: resolvePreviewFileType(resolvedEditPath),
sourceTool: 'edit_block',
...await getDefaultEditorMetadata(resolvedEditPath),
},
// structuredContent only travels on widget-initiated calls: it is the
// widget's save-success signal (assertSuccessfulEditBlockResult) and
// metadata source. LLM-facing responses don't need it — nothing there
// consumes it, and the widget re-reads by path instead.
...(origin === 'ui' ? {
structuredContent: {
fileName: path.basename(resolvedEditPath),
filePath: resolvedEditPath,
fileType: resolvePreviewFileType(resolvedEditPath),
...await getDefaultEditorMetadata(resolvedEditPath),
},
} : {}),
};
}

Expand Down Expand Up @@ -437,13 +442,14 @@ export async function handleEditBlock(args: unknown): Promise<ServerResult> {
type: "text",
text: `Successfully updated range ${parsed.range} in ${parsed.file_path}`
}],
structuredContent: {
fileName: path.basename(resolvedRangePath),
filePath: resolvedRangePath,
fileType: resolvePreviewFileType(resolvedRangePath),
sourceTool: 'edit_block',
...await getDefaultEditorMetadata(resolvedRangePath),
},
...(parsed.origin === 'ui' ? {
structuredContent: {
fileName: path.basename(resolvedRangePath),
filePath: resolvedRangePath,
fileType: resolvePreviewFileType(resolvedRangePath),
...await getDefaultEditorMetadata(resolvedRangePath),
},
} : {}),
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
Expand Down Expand Up @@ -477,13 +483,14 @@ export async function handleEditBlock(args: unknown): Promise<ServerResult> {
type: "text",
text: `Successfully applied ${result.editsApplied} edit(s) to ${parsed.file_path}`
}],
structuredContent: {
fileName: path.basename(resolvedEditRangePath),
filePath: resolvedEditRangePath,
fileType: resolvePreviewFileType(resolvedEditRangePath),
sourceTool: 'edit_block',
...await getDefaultEditorMetadata(resolvedEditRangePath),
},
...(parsed.origin === 'ui' ? {
structuredContent: {
fileName: path.basename(resolvedEditRangePath),
filePath: resolvedEditRangePath,
fileType: resolvePreviewFileType(resolvedEditRangePath),
...await getDefaultEditorMetadata(resolvedEditRangePath),
},
} : {}),
};
}

Expand All @@ -498,5 +505,5 @@ export async function handleEditBlock(args: unknown): Promise<ServerResult> {
return performSearchReplace(parsed.file_path, {
search: parsed.old_string,
replace: parsed.new_string
}, parsed.expected_replacements);
}, parsed.expected_replacements, parsed.origin);
}
Loading
Loading