Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 9d67aea

Browse files
committed
feat: unify @ mention and read_file truncation format
- Add truncation support to extractTextFromFile via readWithSlice (2000 line limit) - Update parseMentions to return file content as separate MentionContentBlock objects - Update processUserContentMentions to handle new contentBlocks structure - Add Gemini-style truncation warnings with IMPORTANT header - Sync truncation message format between read_file tool and @ mentions - Put truncation warning at TOP (before content) in both implementations - Update test mocks and expectations for new behavior
1 parent db8e356 commit 9d67aea

5 files changed

Lines changed: 312 additions & 81 deletions

File tree

src/core/mentions/__tests__/processUserContentMentions.spec.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ describe("processUserContentMentions", () => {
2626
vi.mocked(parseMentions).mockImplementation(async (text) => ({
2727
text: `parsed: ${text}`,
2828
mode: undefined,
29+
contentBlocks: [],
2930
}))
3031
})
3132

@@ -90,10 +91,16 @@ describe("processUserContentMentions", () => {
9091
})
9192

9293
expect(parseMentions).toHaveBeenCalled()
94+
// String content is now converted to array format to support content blocks
9395
expect(result.content[0]).toEqual({
9496
type: "tool_result",
9597
tool_use_id: "123",
96-
content: "parsed: <user_message>Tool feedback</user_message>",
98+
content: [
99+
{
100+
type: "text",
101+
text: "parsed: <user_message>Tool feedback</user_message>",
102+
},
103+
],
97104
})
98105
expect(result.mode).toBeUndefined()
99106
})
@@ -176,10 +183,16 @@ describe("processUserContentMentions", () => {
176183
text: "parsed: <user_message>First task</user_message>",
177184
})
178185
expect(result.content[1]).toEqual(userContent[1]) // Image block unchanged
186+
// String content is now converted to array format to support content blocks
179187
expect(result.content[2]).toEqual({
180188
type: "tool_result",
181189
tool_use_id: "456",
182-
content: "parsed: <user_message>Feedback</user_message>",
190+
content: [
191+
{
192+
type: "text",
193+
text: "parsed: <user_message>Feedback</user_message>",
194+
},
195+
],
183196
})
184197
expect(result.mode).toBeUndefined()
185198
})
@@ -248,6 +261,7 @@ describe("processUserContentMentions", () => {
248261
text: "parsed text",
249262
slashCommandHelp: "command help",
250263
mode: undefined,
264+
contentBlocks: [],
251265
})
252266

253267
const userContent = [
@@ -280,6 +294,7 @@ describe("processUserContentMentions", () => {
280294
text: "parsed tool output",
281295
slashCommandHelp: "command help",
282296
mode: undefined,
297+
contentBlocks: [],
283298
})
284299

285300
const userContent = [
@@ -319,6 +334,7 @@ describe("processUserContentMentions", () => {
319334
text: "parsed array item",
320335
slashCommandHelp: "command help",
321336
mode: undefined,
337+
contentBlocks: [],
322338
})
323339

324340
const userContent = [

src/core/mentions/index.ts

Lines changed: 152 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ import { mentionRegexGlobal, commandRegexGlobal, unescapeSpaces } from "../../sh
99
import { getCommitInfo, getWorkingState } from "../../utils/git"
1010

1111
import { openFile } from "../../integrations/misc/open-file"
12-
import { extractTextFromFile } from "../../integrations/misc/extract-text"
12+
import { extractTextFromFileWithMetadata, type ExtractTextResult } from "../../integrations/misc/extract-text"
1313
import { diagnosticsToProblemsString } from "../../integrations/diagnostics"
14+
import { DEFAULT_LINE_LIMIT } from "../prompts/tools/native-tools/read_file"
1415

1516
import { 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+
7495
export 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+
80128
export 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

Comments
 (0)