@@ -9,8 +9,9 @@ import { mentionRegexGlobal, commandRegexGlobal, unescapeSpaces } from "../../sh
99import { getCommitInfo , getWorkingState } from "../../utils/git"
1010
1111import { openFile } from "../../integrations/misc/open-file"
12- import { extractTextFromFile } from "../../integrations/misc/extract-text"
12+ import { extractTextFromFileWithMetadata , type ExtractTextResult } from "../../integrations/misc/extract-text"
1313import { diagnosticsToProblemsString } from "../../integrations/diagnostics"
14+ import { DEFAULT_LINE_LIMIT } from "../prompts/tools/native-tools/read_file"
1415
1516import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
1617
@@ -71,12 +72,59 @@ export async function openMention(cwd: string, mention?: string): Promise<void>
7172 }
7273}
7374
75+ /**
76+ * Represents a content block generated from an @ mention.
77+ * These are returned separately from the user's text to enable
78+ * proper formatting as distinct message blocks.
79+ */
80+ export interface MentionContentBlock {
81+ type : "file" | "folder" | "url" | "diagnostics" | "git_changes" | "git_commit" | "terminal" | "command"
82+ /** Path for file/folder mentions */
83+ path ?: string
84+ /** The content to display */
85+ content : string
86+ /** Metadata about truncation (for files) */
87+ metadata ?: {
88+ totalLines : number
89+ returnedLines : number
90+ wasTruncated : boolean
91+ linesShown ?: [ number , number ]
92+ }
93+ }
94+
7495export interface ParseMentionsResult {
96+ /** User's text with @ mentions replaced by clean path references */
7597 text : string
98+ /** Separate content blocks for each mention (file content, URLs, etc.) */
99+ contentBlocks : MentionContentBlock [ ]
76100 slashCommandHelp ?: string
77101 mode ?: string // Mode from the first slash command that has one
78102}
79103
104+ /**
105+ * Formats file content to look like a read_file tool result.
106+ * Includes Gemini-style truncation warning when content is truncated.
107+ */
108+ function formatFileReadResult ( filePath : string , result : ExtractTextResult ) : string {
109+ const header = `[read_file for '${ filePath } ']`
110+
111+ if ( result . wasTruncated && result . linesShown ) {
112+ const [ start , end ] = result . linesShown
113+ const nextOffset = end + 1
114+ return `${ header }
115+ IMPORTANT: File content truncated.
116+ Status: Showing lines ${ start } -${ end } of ${ result . totalLines } total lines.
117+ To read more: Use the read_file tool with offset=${ nextOffset } and limit=${ DEFAULT_LINE_LIMIT } .
118+
119+ File: ${ filePath }
120+ ${ result . content } `
121+ }
122+
123+ return `${ header }
124+ File: ${ filePath }
125+ ${ result . content } `
126+ }
127+
80128export async function parseMentions (
81129 text : string ,
82130 cwd : string ,
@@ -89,6 +137,7 @@ export async function parseMentions(
89137) : Promise < ParseMentionsResult > {
90138 const mentions : Set < string > = new Set ( )
91139 const validCommands : Map < string , Command > = new Map ( )
140+ const contentBlocks : MentionContentBlock [ ] = [ ]
92141 let commandMode : string | undefined // Track mode from the first slash command that has one
93142
94143 // First pass: check which command mentions exist and cache the results
@@ -118,24 +167,25 @@ export async function parseMentions(
118167 }
119168 }
120169
121- // Only replace text for commands that actually exist
170+ // Only replace text for commands that actually exist (keep "see below" for commands)
122171 let parsedText = text
123172 for ( const [ match , commandName ] of commandMatches ) {
124173 if ( validCommands . has ( commandName ) ) {
125174 parsedText = parsedText . replace ( match , `Command '${ commandName } ' (see below for command content)` )
126175 }
127176 }
128177
129- // Second pass: handle regular mentions
178+ // Second pass: handle regular mentions - replace with clean references
179+ // Content will be provided as separate blocks that look like read_file results
130180 parsedText = parsedText . replace ( mentionRegexGlobal , ( match , mention ) => {
131181 mentions . add ( mention )
132182 if ( mention . startsWith ( "http" ) ) {
183+ // Keep old style for URLs (still XML-based)
133184 return `'${ mention } ' (see below for site content)`
134185 } else if ( mention . startsWith ( "/" ) ) {
186+ // Clean path reference - no "see below" since we format like tool results
135187 const mentionPath = mention . slice ( 1 )
136- return mentionPath . endsWith ( "/" )
137- ? `'${ mentionPath } ' (see below for folder content)`
138- : `'${ mentionPath } ' (see below for file content)`
188+ return mentionPath . endsWith ( "/" ) ? `'${ mentionPath } '` : `'${ mentionPath } '`
139189 } else if ( mention === "problems" ) {
140190 return `Workspace Problems (see below for diagnostics)`
141191 } else if ( mention === "git-changes" ) {
@@ -188,25 +238,26 @@ export async function parseMentions(
188238 result = `Error fetching content: ${ rawErrorMessage } `
189239 }
190240 }
241+ // URLs still use XML format (appended to text for backwards compat)
191242 parsedText += `\n\n<url_content url="${ mention } ">\n${ result } \n</url_content>`
192243 } else if ( mention . startsWith ( "/" ) ) {
193244 const mentionPath = mention . slice ( 1 )
194245 try {
195- const content = await getFileOrFolderContent ( mentionPath , cwd , rooIgnoreController , showRooIgnoredFiles )
196- if ( mention . endsWith ( "/" ) ) {
197- parsedText += `\n\n<folder_content path="${ mentionPath } ">\n${ content } \n</folder_content>`
198- } else {
199- parsedText += `\n\n<file_content path="${ mentionPath } ">\n${ content } \n</file_content>`
200- if ( fileContextTracker ) {
201- await fileContextTracker . trackFileContext ( mentionPath , "file_mentioned" )
202- }
203- }
246+ const fileResult = await getFileOrFolderContentWithMetadata (
247+ mentionPath ,
248+ cwd ,
249+ rooIgnoreController ,
250+ showRooIgnoredFiles ,
251+ fileContextTracker ,
252+ )
253+ contentBlocks . push ( fileResult )
204254 } catch ( error ) {
205- if ( mention . endsWith ( "/" ) ) {
206- parsedText += `\n\n<folder_content path="${ mentionPath } ">\nError fetching content: ${ error . message } \n</folder_content>`
207- } else {
208- parsedText += `\n\n<file_content path="${ mentionPath } ">\nError fetching content: ${ error . message } \n</file_content>`
209- }
255+ const errorMsg = error instanceof Error ? error . message : String ( error )
256+ contentBlocks . push ( {
257+ type : mention . endsWith ( "/" ) ? "folder" : "file" ,
258+ path : mentionPath ,
259+ content : `[read_file for '${ mentionPath } ']\nError: ${ errorMsg } ` ,
260+ } )
210261 }
211262 } else if ( mention === "problems" ) {
212263 try {
@@ -262,17 +313,28 @@ export async function parseMentions(
262313 }
263314 }
264315
265- return { text : parsedText , mode : commandMode , slashCommandHelp : slashCommandHelp . trim ( ) || undefined }
316+ return {
317+ text : parsedText ,
318+ contentBlocks,
319+ mode : commandMode ,
320+ slashCommandHelp : slashCommandHelp . trim ( ) || undefined ,
321+ }
266322}
267323
268- async function getFileOrFolderContent (
324+ /**
325+ * Gets file or folder content and returns it as a MentionContentBlock
326+ * formatted to look like a read_file tool result.
327+ */
328+ async function getFileOrFolderContentWithMetadata (
269329 mentionPath : string ,
270330 cwd : string ,
271331 rooIgnoreController ?: any ,
272332 showRooIgnoredFiles : boolean = false ,
273- ) : Promise < string > {
333+ fileContextTracker ?: FileContextTracker ,
334+ ) : Promise < MentionContentBlock > {
274335 const unescapedPath = unescapeSpaces ( mentionPath )
275336 const absPath = path . resolve ( cwd , unescapedPath )
337+ const isFolder = mentionPath . endsWith ( "/" )
276338
277339 try {
278340 const stats = await fs . stat ( absPath )
@@ -282,21 +344,50 @@ async function getFileOrFolderContent(
282344 // Image mentions are handled separately via image attachment flow.
283345 const isBinary = await isBinaryFile ( absPath ) . catch ( ( ) => false )
284346 if ( isBinary ) {
285- return `(Binary file ${ mentionPath } omitted)`
347+ return {
348+ type : "file" ,
349+ path : mentionPath ,
350+ content : `[read_file for '${ mentionPath } ']\nNote: Binary file omitted from context.` ,
351+ }
286352 }
287353 if ( rooIgnoreController && ! rooIgnoreController . validateAccess ( unescapedPath ) ) {
288- return `(File ${ mentionPath } is ignored by .rooignore)`
354+ return {
355+ type : "file" ,
356+ path : mentionPath ,
357+ content : `[read_file for '${ mentionPath } ']\nNote: File is ignored by .rooignore.` ,
358+ }
289359 }
290360 try {
291- const content = await extractTextFromFile ( absPath )
292- return content
361+ const result = await extractTextFromFileWithMetadata ( absPath )
362+
363+ // Track file context
364+ if ( fileContextTracker ) {
365+ await fileContextTracker . trackFileContext ( mentionPath , "file_mentioned" )
366+ }
367+
368+ return {
369+ type : "file" ,
370+ path : mentionPath ,
371+ content : formatFileReadResult ( mentionPath , result ) ,
372+ metadata : {
373+ totalLines : result . totalLines ,
374+ returnedLines : result . returnedLines ,
375+ wasTruncated : result . wasTruncated ,
376+ linesShown : result . linesShown ,
377+ } ,
378+ }
293379 } catch ( error ) {
294- return `(Failed to read contents of ${ mentionPath } ): ${ error . message } `
380+ const errorMsg = error instanceof Error ? error . message : String ( error )
381+ return {
382+ type : "file" ,
383+ path : mentionPath ,
384+ content : `[read_file for '${ mentionPath } ']\nError: ${ errorMsg } ` ,
385+ }
295386 }
296387 } else if ( stats . isDirectory ( ) ) {
297388 const entries = await fs . readdir ( absPath , { withFileTypes : true } )
298- let folderContent = ""
299- const fileContentPromises : Promise < string | undefined > [ ] = [ ]
389+ let folderListing = ""
390+ const fileReadResults : string [ ] = [ ]
300391 const LOCK_SYMBOL = "🔒"
301392
302393 for ( let index = 0 ; index < entries . length ; index ++ ) {
@@ -317,38 +408,48 @@ async function getFileOrFolderContent(
317408 const displayName = isIgnored ? `${ LOCK_SYMBOL } ${ entry . name } ` : entry . name
318409
319410 if ( entry . isFile ( ) ) {
320- folderContent += `${ linePrefix } ${ displayName } \n`
411+ folderListing += `${ linePrefix } ${ displayName } \n`
321412 if ( ! isIgnored ) {
322413 const filePath = path . join ( mentionPath , entry . name )
323414 const absoluteFilePath = path . resolve ( absPath , entry . name )
324- fileContentPromises . push (
325- ( async ( ) => {
326- try {
327- const isBinary = await isBinaryFile ( absoluteFilePath ) . catch ( ( ) => false )
328- if ( isBinary ) {
329- return undefined
330- }
331- const content = await extractTextFromFile ( absoluteFilePath )
332- return `<file_content path="${ filePath . toPosix ( ) } ">\n${ content } \n</file_content>`
333- } catch ( error ) {
334- return undefined
335- }
336- } ) ( ) ,
337- )
415+ try {
416+ const isBinary = await isBinaryFile ( absoluteFilePath ) . catch ( ( ) => false )
417+ if ( ! isBinary ) {
418+ const result = await extractTextFromFileWithMetadata ( absoluteFilePath )
419+ fileReadResults . push ( formatFileReadResult ( filePath . toPosix ( ) , result ) )
420+ }
421+ } catch ( error ) {
422+ // Skip files that can't be read
423+ }
338424 }
339425 } else if ( entry . isDirectory ( ) ) {
340- folderContent += `${ linePrefix } ${ displayName } /\n`
426+ folderListing += `${ linePrefix } ${ displayName } /\n`
341427 } else {
342- folderContent += `${ linePrefix } ${ displayName } \n`
428+ folderListing += `${ linePrefix } ${ displayName } \n`
343429 }
344430 }
345- const fileContents = ( await Promise . all ( fileContentPromises ) ) . filter ( ( content ) => content )
346- return `${ folderContent } \n${ fileContents . join ( "\n\n" ) } ` . trim ( )
431+
432+ // Format folder content similar to read_file output
433+ let content = `[read_file for folder '${ mentionPath } ']\nFolder listing:\n${ folderListing } `
434+ if ( fileReadResults . length > 0 ) {
435+ content += `\n\n--- File Contents ---\n\n${ fileReadResults . join ( "\n\n" ) } `
436+ }
437+
438+ return {
439+ type : "folder" ,
440+ path : mentionPath ,
441+ content,
442+ }
347443 } else {
348- return `(Failed to read contents of ${ mentionPath } )`
444+ return {
445+ type : isFolder ? "folder" : "file" ,
446+ path : mentionPath ,
447+ content : `[read_file for '${ mentionPath } ']\nError: Unable to read (not a file or directory)` ,
448+ }
349449 }
350450 } catch ( error ) {
351- throw new Error ( `Failed to access path "${ mentionPath } ": ${ error . message } ` )
451+ const errorMsg = error instanceof Error ? error . message : String ( error )
452+ throw new Error ( `Failed to access path "${ mentionPath } ": ${ errorMsg } ` )
352453 }
353454}
354455
0 commit comments