ソースを参照

Support mentioning filenames with spaces (#3044)

* Support mentioning filenames with spaces

Allows spaces to be escaped in mentions with \. Does not attempt more
comprehensive escape handling. (IMO a more structured representation or
moving to something like @[something complex] is probably a better long
term approach, but the scope of that seems problematic.

Adapted from 8955d45708eb78f2754921099a291628203f4069.

Co-authored-by: Matt Rubens <[email protected]>

* Older array creation to make check-types happier

---------

Co-authored-by: Matt Rubens <[email protected]>
John Richmond 9 ヶ月 前
コミット
34d7dcf8c3

+ 141 - 19
src/core/mentions/__tests__/index.test.ts

@@ -87,6 +87,24 @@ import * as git from "../../../utils/git"
 import { getWorkspacePath } from "../../../utils/path"
 import { getWorkspacePath } from "../../../utils/path"
 ;(getWorkspacePath as jest.Mock).mockReturnValue("/test/workspace")
 ;(getWorkspacePath as jest.Mock).mockReturnValue("/test/workspace")
 
 
+jest.mock("fs/promises", () => ({
+	stat: jest.fn(),
+	readdir: jest.fn(),
+}))
+import fs from "fs/promises"
+import * as path from "path"
+
+jest.mock("../../../integrations/misc/open-file", () => ({
+	openFile: jest.fn(),
+}))
+import { openFile } from "../../../integrations/misc/open-file"
+
+jest.mock("../../../integrations/misc/extract-text", () => ({
+	extractTextFromFile: jest.fn(),
+}))
+
+import * as vscode from "vscode"
+
 describe("mentions", () => {
 describe("mentions", () => {
 	const mockCwd = "/test/workspace"
 	const mockCwd = "/test/workspace"
 	let mockUrlContentFetcher: UrlContentFetcher
 	let mockUrlContentFetcher: UrlContentFetcher
@@ -112,6 +130,16 @@ describe("mentions", () => {
 	})
 	})
 
 
 	describe("parseMentions", () => {
 	describe("parseMentions", () => {
+		let mockUrlFetcher: UrlContentFetcher
+
+		beforeEach(() => {
+			mockUrlFetcher = new (UrlContentFetcher as jest.Mock<UrlContentFetcher>)()
+			;(fs.stat as jest.Mock).mockResolvedValue({ isFile: () => true, isDirectory: () => false })
+			;(require("../../../integrations/misc/extract-text").extractTextFromFile as jest.Mock).mockResolvedValue(
+				"Mock file content",
+			)
+		})
+
 		it("should parse git commit mentions", async () => {
 		it("should parse git commit mentions", async () => {
 			const commitHash = "abc1234"
 			const commitHash = "abc1234"
 			const commitInfo = `abc1234 Fix bug in parser
 			const commitInfo = `abc1234 Fix bug in parser
@@ -144,35 +172,72 @@ Detailed commit message with multiple lines
 			expect(result).toContain(`<git_commit hash="${commitHash}">`)
 			expect(result).toContain(`<git_commit hash="${commitHash}">`)
 			expect(result).toContain(`Error fetching commit info: ${errorMessage}`)
 			expect(result).toContain(`Error fetching commit info: ${errorMessage}`)
 		})
 		})
-	})
 
 
-	describe("openMention", () => {
-		it("should handle file paths and problems", async () => {
-			// Mock stat to simulate file not existing
-			mockVscode.workspace.fs.stat.mockRejectedValueOnce(new Error("File does not exist"))
+		it("should correctly parse mentions with escaped spaces and fetch content", async () => {
+			const text = "Please check the file @/path/to/file\\ with\\ spaces.txt"
+			const expectedUnescaped = "path/to/file with spaces.txt" // Note: leading '/' removed by slice(1) in parseMentions
+			const expectedAbsPath = path.resolve(mockCwd, expectedUnescaped)
 
 
-			// Call openMention and wait for it to complete
-			await openMention("/path/to/file")
+			const result = await parseMentions(text, mockCwd, mockUrlFetcher)
 
 
-			// Verify error handling
-			expect(mockExecuteCommand).not.toHaveBeenCalled()
-			expect(mockOpenExternal).not.toHaveBeenCalled()
-			expect(mockVscode.window.showErrorMessage).toHaveBeenCalledWith("Could not open file: File does not exist")
+			// Check if fs.stat was called with the unescaped path
+			expect(fs.stat).toHaveBeenCalledWith(expectedAbsPath)
+			// Check if extractTextFromFile was called with the unescaped path
+			expect(require("../../../integrations/misc/extract-text").extractTextFromFile).toHaveBeenCalledWith(
+				expectedAbsPath,
+			)
 
 
-			// Reset mocks for next test
-			jest.clearAllMocks()
+			// Check the output format
+			expect(result).toContain(`'path/to/file\\ with\\ spaces.txt' (see below for file content)`)
+			expect(result).toContain(
+				`<file_content path="path/to/file\\ with\\ spaces.txt">\nMock file content\n</file_content>`,
+			)
+		})
 
 
-			// Test problems command
-			await openMention("problems")
-			expect(mockExecuteCommand).toHaveBeenCalledWith("workbench.actions.view.problems")
+		it("should handle folder mentions with escaped spaces", async () => {
+			const text = "Look in @/my\\ documents/folder\\ name/"
+			const expectedUnescaped = "my documents/folder name/"
+			const expectedAbsPath = path.resolve(mockCwd, expectedUnescaped)
+			;(fs.stat as jest.Mock).mockResolvedValue({ isFile: () => false, isDirectory: () => true })
+			;(fs.readdir as jest.Mock).mockResolvedValue([]) // Empty directory
+
+			const result = await parseMentions(text, mockCwd, mockUrlFetcher)
+
+			expect(fs.stat).toHaveBeenCalledWith(expectedAbsPath)
+			expect(fs.readdir).toHaveBeenCalledWith(expectedAbsPath, { withFileTypes: true })
+			expect(result).toContain(`'my\\ documents/folder\\ name/' (see below for folder content)`)
+			expect(result).toContain(`<folder_content path="my\\ documents/folder\\ name/">`) // Content check might be more complex
+		})
+
+		it("should handle errors when accessing paths with escaped spaces", async () => {
+			const text = "Check @/nonexistent\\ file.txt"
+			const expectedUnescaped = "nonexistent file.txt"
+			const expectedAbsPath = path.resolve(mockCwd, expectedUnescaped)
+			const mockError = new Error("ENOENT: no such file or directory")
+			;(fs.stat as jest.Mock).mockRejectedValue(mockError)
+
+			const result = await parseMentions(text, mockCwd, mockUrlFetcher)
+
+			expect(fs.stat).toHaveBeenCalledWith(expectedAbsPath)
+			expect(result).toContain(
+				`<file_content path="nonexistent\\ file.txt">\nError fetching content: Failed to access path "nonexistent\\ file.txt": ${mockError.message}\n</file_content>`,
+			)
+		})
+
+		// Add more tests for parseMentions if needed (URLs, other mentions combined with escaped paths etc.)
+	})
+
+	describe("openMention", () => {
+		beforeEach(() => {
+			;(getWorkspacePath as jest.Mock).mockReturnValue(mockCwd)
 		})
 		})
 
 
 		it("should handle URLs", async () => {
 		it("should handle URLs", async () => {
 			const url = "https://example.com"
 			const url = "https://example.com"
 			await openMention(url)
 			await openMention(url)
-			const mockUri = mockVscode.Uri.parse(url)
-			expect(mockVscode.env.openExternal).toHaveBeenCalled()
-			const calledArg = mockVscode.env.openExternal.mock.calls[0][0]
+			const mockUri = vscode.Uri.parse(url)
+			expect(vscode.env.openExternal).toHaveBeenCalled()
+			const calledArg = (vscode.env.openExternal as jest.Mock).mock.calls[0][0]
 			expect(calledArg).toEqual(
 			expect(calledArg).toEqual(
 				expect.objectContaining({
 				expect.objectContaining({
 					scheme: mockUri.scheme,
 					scheme: mockUri.scheme,
@@ -183,5 +248,62 @@ Detailed commit message with multiple lines
 				}),
 				}),
 			)
 			)
 		})
 		})
+
+		it("should unescape file path before opening", async () => {
+			const mention = "/file\\ with\\ spaces.txt"
+			const expectedUnescaped = "file with spaces.txt"
+			const expectedAbsPath = path.resolve(mockCwd, expectedUnescaped)
+
+			await openMention(mention)
+
+			expect(openFile).toHaveBeenCalledWith(expectedAbsPath)
+			expect(vscode.commands.executeCommand).not.toHaveBeenCalled()
+		})
+
+		it("should unescape folder path before revealing", async () => {
+			const mention = "/folder\\ with\\ spaces/"
+			const expectedUnescaped = "folder with spaces/"
+			const expectedAbsPath = path.resolve(mockCwd, expectedUnescaped)
+			const expectedUri = { fsPath: expectedAbsPath } // From mock
+			;(vscode.Uri.file as jest.Mock).mockReturnValue(expectedUri)
+
+			await openMention(mention)
+
+			expect(vscode.commands.executeCommand).toHaveBeenCalledWith("revealInExplorer", expectedUri)
+			expect(vscode.Uri.file).toHaveBeenCalledWith(expectedAbsPath)
+			expect(openFile).not.toHaveBeenCalled()
+		})
+
+		it("should handle mentions without paths correctly", async () => {
+			await openMention("problems")
+			expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.actions.view.problems")
+
+			await openMention("terminal")
+			expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.action.terminal.focus")
+
+			await openMention("http://example.com")
+			expect(vscode.env.openExternal).toHaveBeenCalled() // Check if called, specific URI mock might be needed for detailed check
+
+			await openMention("git-changes") // Assuming no specific action for this yet
+			// Add expectations if an action is defined for git-changes
+
+			await openMention("a1b2c3d") // Assuming no specific action for commit hashes yet
+			// Add expectations if an action is defined for commit hashes
+		})
+
+		it("should do nothing if mention is undefined or empty", async () => {
+			await openMention(undefined)
+			await openMention("")
+			expect(openFile).not.toHaveBeenCalled()
+			expect(vscode.commands.executeCommand).not.toHaveBeenCalled()
+			expect(vscode.env.openExternal).not.toHaveBeenCalled()
+		})
+
+		it("should do nothing if cwd is not available", async () => {
+			;(getWorkspacePath as jest.Mock).mockReturnValue(undefined)
+			await openMention("/some\\ path.txt")
+			expect(openFile).not.toHaveBeenCalled()
+			expect(vscode.commands.executeCommand).not.toHaveBeenCalled()
+		})
 	})
 	})
 })
 })

+ 6 - 3
src/core/mentions/index.ts

@@ -6,7 +6,7 @@ import { isBinaryFile } from "isbinaryfile"
 
 
 import { openFile } from "../../integrations/misc/open-file"
 import { openFile } from "../../integrations/misc/open-file"
 import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
 import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
-import { mentionRegexGlobal } from "../../shared/context-mentions"
+import { mentionRegexGlobal, unescapeSpaces } from "../../shared/context-mentions"
 
 
 import { extractTextFromFile } from "../../integrations/misc/extract-text"
 import { extractTextFromFile } from "../../integrations/misc/extract-text"
 import { diagnosticsToProblemsString } from "../../integrations/diagnostics"
 import { diagnosticsToProblemsString } from "../../integrations/diagnostics"
@@ -25,7 +25,8 @@ export async function openMention(mention?: string): Promise<void> {
 	}
 	}
 
 
 	if (mention.startsWith("/")) {
 	if (mention.startsWith("/")) {
-		const relPath = mention.slice(1)
+		// Slice off the leading slash and unescape any spaces in the path
+		const relPath = unescapeSpaces(mention.slice(1))
 		const absPath = path.resolve(cwd, relPath)
 		const absPath = path.resolve(cwd, relPath)
 		if (mention.endsWith("/")) {
 		if (mention.endsWith("/")) {
 			vscode.commands.executeCommand("revealInExplorer", vscode.Uri.file(absPath))
 			vscode.commands.executeCommand("revealInExplorer", vscode.Uri.file(absPath))
@@ -158,7 +159,9 @@ export async function parseMentions(
 }
 }
 
 
 async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise<string> {
 async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise<string> {
-	const absPath = path.resolve(cwd, mentionPath)
+	// Unescape spaces in the path before resolving it
+	const unescapedPath = unescapeSpaces(mentionPath)
+	const absPath = path.resolve(cwd, unescapedPath)
 
 
 	try {
 	try {
 		const stats = await fs.stat(absPath)
 		const stats = await fs.stat(absPath)

+ 79 - 0
src/shared/__tests__/context-mentions.test.ts

@@ -0,0 +1,79 @@
+import { mentionRegex, mentionRegexGlobal } from "../context-mentions"
+
+describe("mentionRegex and mentionRegexGlobal", () => {
+	// Test cases for various mention types
+	const testCases = [
+		// Basic file paths
+		{ input: "@/path/to/file.txt", expected: ["@/path/to/file.txt"] },
+		{ input: "@/file.js", expected: ["@/file.js"] },
+		{ input: "@/folder/", expected: ["@/folder/"] },
+
+		// File paths with escaped spaces
+		{ input: "@/path/to/file\\ with\\ spaces.txt", expected: ["@/path/to/file\\ with\\ spaces.txt"] },
+		{ input: "@/users/my\\ project/report\\ final.pdf", expected: ["@/users/my\\ project/report\\ final.pdf"] },
+		{ input: "@/folder\\ with\\ spaces/", expected: ["@/folder\\ with\\ spaces/"] },
+		{ input: "@/a\\ b\\ c.txt", expected: ["@/a\\ b\\ c.txt"] },
+
+		// URLs
+		{ input: "@http://example.com", expected: ["@http://example.com"] },
+		{ input: "@https://example.com/path?query=1", expected: ["@https://example.com/path?query=1"] },
+
+		// Other mentions
+		{ input: "@problems", expected: ["@problems"] },
+		{ input: "@git-changes", expected: ["@git-changes"] },
+		{ input: "@terminal", expected: ["@terminal"] },
+		{ input: "@a1b2c3d", expected: ["@a1b2c3d"] }, // Git commit hash (short)
+		{ input: "@a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", expected: ["@a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"] }, // Git commit hash (long)
+
+		// Mentions within text
+		{
+			input: "Check file @/path/to/file\\ with\\ spaces.txt for details.",
+			expected: ["@/path/to/file\\ with\\ spaces.txt"],
+		},
+		{ input: "See @problems and @terminal output.", expected: ["@problems", "@terminal"] },
+		{ input: "URL: @https://example.com.", expected: ["@https://example.com"] }, // Trailing punctuation
+		{ input: "Commit @a1b2c3d, then check @/file.txt", expected: ["@a1b2c3d", "@/file.txt"] },
+
+		// Negative cases (should not match or match partially)
+		{ input: "@/path/with unescaped space.txt", expected: ["@/path/with"] }, // Unescaped space
+		{ input: "@ /path/leading-space.txt", expected: null }, // Space after @
+		{ input: "[email protected]", expected: null }, // Email address
+		{ input: "mention@", expected: null }, // Trailing @
+		{ input: "@/path/trailing\\", expected: null }, // Trailing backslash (invalid escape)
+		{ input: "@/path/to/file\\not-a-space", expected: null }, // Backslash not followed by space
+	]
+
+	testCases.forEach(({ input, expected }) => {
+		it(`should handle input: "${input}"`, () => {
+			// Test mentionRegex (first match)
+			const match = input.match(mentionRegex)
+			const firstExpected = expected ? expected[0] : null
+			if (firstExpected) {
+				expect(match).not.toBeNull()
+				// Check the full match (group 0)
+				expect(match?.[0]).toBe(firstExpected)
+				// Check the captured group (group 1) - remove leading '@'
+				expect(match?.[1]).toBe(firstExpected.slice(1))
+			} else {
+				expect(match).toBeNull()
+			}
+
+			// Test mentionRegexGlobal (all matches)
+			const globalMatches = Array.from(input.matchAll(mentionRegexGlobal)).map((m) => m[0])
+			if (expected) {
+				expect(globalMatches).toEqual(expected)
+			} else {
+				expect(globalMatches).toEqual([])
+			}
+		})
+	})
+
+	it("should correctly capture the mention part (group 1)", () => {
+		const input = "Mention @/path/to/escaped\\ file.txt and @problems"
+		const matches = Array.from(input.matchAll(mentionRegexGlobal))
+
+		expect(matches.length).toBe(2)
+		expect(matches[0][1]).toBe("/path/to/escaped\\ file.txt") // Group 1 should not include '@'
+		expect(matches[1][1]).toBe("problems")
+	})
+})

+ 14 - 5
src/shared/context-mentions.ts

@@ -16,10 +16,13 @@ Mention regex:
 	  - `\/`: 
 	  - `\/`: 
 		- **Slash (`/`)**: Indicates that the mention is a file or folder path starting with a '/'.
 		- **Slash (`/`)**: Indicates that the mention is a file or folder path starting with a '/'.
 	  - `|`: Logical OR.
 	  - `|`: Logical OR.
-	  - `\w+:\/\/`: 
-		- **Protocol (`\w+://`)**: Matches URLs that start with a word character sequence followed by '://', such as 'http://', 'https://', 'ftp://', etc.
-	- `[^\s]+?`: 
-	  - **Non-Whitespace Characters (`[^\s]+`)**: Matches one or more characters that are not whitespace.
+	  - `\w+:\/\/`:
+	    - **Protocol (`\w+://`)**: Matches URLs that start with a word character sequence followed by '://', such as 'http://', 'https://', 'ftp://', etc.
+	- `(?:[^\s\\]|\\ )+?`:
+	  - **Non-Capturing Group (`(?:...)`)**: Groups the alternatives without capturing them.
+	  - **Non-Whitespace and Non-Backslash (`[^\s\\]`)**: Matches any character that is not whitespace or a backslash.
+	  - **OR (`|`)**: Logical OR.
+	  - **Escaped Space (`\\ `)**: Matches a backslash followed by a space (an escaped space).
 	  - **Non-Greedy (`+?`)**: Ensures the smallest possible match, preventing the inclusion of trailing punctuation.
 	  - **Non-Greedy (`+?`)**: Ensures the smallest possible match, preventing the inclusion of trailing punctuation.
 	- `|`: Logical OR.
 	- `|`: Logical OR.
 	- `problems\b`: 
 	- `problems\b`: 
@@ -39,6 +42,7 @@ Mention regex:
 - **Summary**:
 - **Summary**:
   - The regex effectively matches:
   - The regex effectively matches:
 	- Mentions that are file or folder paths starting with '/' and containing any non-whitespace characters (including periods within the path).
 	- Mentions that are file or folder paths starting with '/' and containing any non-whitespace characters (including periods within the path).
+	- File paths can include spaces if they are escaped with a backslash (e.g., `@/path/to/file\ with\ spaces.txt`).
 	- URLs that start with a protocol (like 'http://') followed by any non-whitespace characters (including query parameters).
 	- URLs that start with a protocol (like 'http://') followed by any non-whitespace characters (including query parameters).
 	- The exact word 'problems'.
 	- The exact word 'problems'.
 	- The exact word 'git-changes'.
 	- The exact word 'git-changes'.
@@ -50,7 +54,7 @@ Mention regex:
 
 
 */
 */
 export const mentionRegex =
 export const mentionRegex =
-	/@((?:\/|\w+:\/\/)[^\s]+?|[a-f0-9]{7,40}\b|problems\b|git-changes\b|terminal\b)(?=[.,;:!?]?(?=[\s\r\n]|$))/
+	/@((?:\/|\w+:\/\/)(?:[^\s\\]|\\ )+?|[a-f0-9]{7,40}\b|problems\b|git-changes\b|terminal\b)(?=[.,;:!?]?(?=[\s\r\n]|$))/
 export const mentionRegexGlobal = new RegExp(mentionRegex.source, "g")
 export const mentionRegexGlobal = new RegExp(mentionRegex.source, "g")
 
 
 export interface MentionSuggestion {
 export interface MentionSuggestion {
@@ -90,3 +94,8 @@ export function formatGitSuggestion(commit: {
 		date: commit.date,
 		date: commit.date,
 	}
 	}
 }
 }
+
+// Helper function to unescape paths with backslash-escaped spaces
+export function unescapeSpaces(path: string): string {
+	return path.replace(/\\ /g, " ")
+}

+ 2 - 2
webview-ui/src/components/chat/ChatTextArea.tsx

@@ -2,7 +2,7 @@ import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, us
 import { useEvent } from "react-use"
 import { useEvent } from "react-use"
 import DynamicTextArea from "react-textarea-autosize"
 import DynamicTextArea from "react-textarea-autosize"
 
 
-import { mentionRegex, mentionRegexGlobal } from "@roo/shared/context-mentions"
+import { mentionRegex, mentionRegexGlobal, unescapeSpaces } from "@roo/shared/context-mentions"
 import { WebviewMessage } from "@roo/shared/WebviewMessage"
 import { WebviewMessage } from "@roo/shared/WebviewMessage"
 import { Mode, getAllModes } from "@roo/shared/modes"
 import { Mode, getAllModes } from "@roo/shared/modes"
 import { ExtensionMessage } from "@roo/shared/ExtensionMessage"
 import { ExtensionMessage } from "@roo/shared/ExtensionMessage"
@@ -470,7 +470,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 								// Send message to extension to search files
 								// Send message to extension to search files
 								vscode.postMessage({
 								vscode.postMessage({
 									type: "searchFiles",
 									type: "searchFiles",
-									query: query,
+									query: unescapeSpaces(query),
 									requestId: reqId,
 									requestId: reqId,
 								})
 								})
 							}, 200) // 200ms debounce
 							}, 200) // 200ms debounce

+ 174 - 3
webview-ui/src/utils/__tests__/context-mentions.test.ts

@@ -5,6 +5,7 @@ import {
 	shouldShowContextMenu,
 	shouldShowContextMenu,
 	ContextMenuOptionType,
 	ContextMenuOptionType,
 	ContextMenuQueryItem,
 	ContextMenuQueryItem,
+	SearchResult,
 } from "@src/utils/context-mentions"
 } from "@src/utils/context-mentions"
 
 
 describe("insertMention", () => {
 describe("insertMention", () => {
@@ -25,6 +26,83 @@ describe("insertMention", () => {
 		expect(result.newValue).toBe("@test ")
 		expect(result.newValue).toBe("@test ")
 		expect(result.mentionIndex).toBe(0)
 		expect(result.mentionIndex).toBe(0)
 	})
 	})
+	it("should replace partial mention after @", () => {
+		const result = insertMention("Mention @fi", 11, "/path/to/file.txt") // Cursor after 'i'
+		expect(result.newValue).toBe("Mention @/path/to/file.txt ") // Space added after mention
+		expect(result.mentionIndex).toBe(8)
+	})
+
+	it("should add a space after the inserted mention", () => {
+		const result = insertMention("Hello ", 6, "terminal") // Cursor at the end
+		expect(result.newValue).toBe("Hello @terminal ")
+		expect(result.mentionIndex).toBe(6)
+	})
+
+	it("should handle insertion at the beginning", () => {
+		const result = insertMention("world", 0, "problems")
+		expect(result.newValue).toBe("@problems world")
+		expect(result.mentionIndex).toBe(0)
+	})
+
+	it("should handle insertion at the end", () => {
+		const result = insertMention("Hello", 5, "problems")
+		expect(result.newValue).toBe("Hello@problems ")
+		expect(result.mentionIndex).toBe(5)
+	})
+
+	it("should handle slash command replacement", () => {
+		const result = insertMention("/mode some", 5, "code") // Simulating mode selection
+		expect(result.newValue).toBe("code") // Should replace the whole text
+		expect(result.mentionIndex).toBe(0)
+	})
+
+	// --- Tests for Escaped Spaces ---
+	it("should NOT escape spaces for non-path mentions", () => {
+		const result = insertMention("Hello @abc ", 10, "git commit with spaces") // Not a path
+		expect(result.newValue).toBe("Hello @git commit with spaces  ")
+	})
+
+	it("should escape spaces when inserting a file path mention with spaces", () => {
+		const filePath = "/path/to/file with spaces.txt"
+		const expectedEscapedPath = "/path/to/file\\ with\\ spaces.txt"
+		const result = insertMention("Mention @old", 11, filePath)
+
+		expect(result.newValue).toBe(`Mention @${expectedEscapedPath} `)
+		expect(result.mentionIndex).toBe(8)
+		// Verify escapeSpaces was effectively used (implicitly by checking output)
+		expect(result.newValue).toContain("\\ ")
+	})
+
+	it("should escape spaces when inserting a folder path mention with spaces", () => {
+		const folderPath = "/my documents/folder name/"
+		const expectedEscapedPath = "/my\\ documents/folder\\ name/"
+		const result = insertMention("Check @dir", 9, folderPath)
+
+		expect(result.newValue).toBe(`Check @${expectedEscapedPath} `)
+		expect(result.mentionIndex).toBe(6)
+		expect(result.newValue).toContain("\\ ")
+	})
+
+	it("should NOT escape spaces if the path value already contains escaped spaces", () => {
+		const alreadyEscapedPath = "/path/already\\ escaped.txt"
+		const result = insertMention("Insert @path", 11, alreadyEscapedPath)
+
+		// It should insert the already escaped path without double-escaping
+		expect(result.newValue).toBe(`Insert @${alreadyEscapedPath} `)
+		expect(result.mentionIndex).toBe(7)
+		// Check that it wasn't passed through escapeSpaces again (mock check)
+		// This relies on the mock implementation detail or careful checking
+		// A better check might be ensuring no double backslashes appear unexpectedly.
+		expect(result.newValue.includes("\\\\ ")).toBe(false)
+	})
+
+	it("should NOT escape spaces for paths without spaces", () => {
+		const simplePath = "/path/to/file.txt"
+		const result = insertMention("Simple @p", 9, simplePath)
+		expect(result.newValue).toBe(`Simple @${simplePath} `)
+		expect(result.mentionIndex).toBe(7)
+		expect(result.newValue.includes("\\ ")).toBe(false)
+	})
 })
 })
 
 
 describe("removeMention", () => {
 describe("removeMention", () => {
@@ -46,6 +124,28 @@ describe("removeMention", () => {
 		expect(result.newText).toBe("Hello world")
 		expect(result.newText).toBe("Hello world")
 		expect(result.newPosition).toBe(5)
 		expect(result.newPosition).toBe(5)
 	})
 	})
+
+	// --- Tests for Escaped Spaces ---
+	it("should not remove mention with escaped spaces if cursor is at the end - KNOWN LIMITATION", () => {
+		// NOTE: This is a known limitation - the current regex in removeMention
+		// doesn't handle escaped spaces well because the regex engine needs
+		// special lookbehind assertions for that.
+		// For now, we're documenting this as a known limitation.
+		const text = "File @/path/to/file\\ with\\ spaces.txt "
+		const position = text.length // Cursor at the very end
+		const { newText, newPosition } = removeMention(text, position)
+		// The mention with escaped spaces won't be matched by the regex
+		expect(newText).toBe(text)
+		expect(newPosition).toBe(position)
+	})
+
+	it("should remove mention with escaped spaces and the following space", () => {
+		const text = "File @/path/to/file\\ with\\ spaces.txt next word"
+		const position = text.indexOf(" next") // Cursor right after the mention + space
+		const { newText, newPosition } = removeMention(text, position)
+		expect(newText).toBe("File next word")
+		expect(newPosition).toBe(5)
+	})
 })
 })
 
 
 describe("getContextMenuOptions", () => {
 describe("getContextMenuOptions", () => {
@@ -58,8 +158,8 @@ describe("getContextMenuOptions", () => {
 		},
 		},
 		{
 		{
 			type: ContextMenuOptionType.OpenedFile,
 			type: ContextMenuOptionType.OpenedFile,
-			value: "src/opened.ts",
-			label: "opened.ts",
+			value: "src/open file.ts",
+			label: "open file.ts",
 			description: "Currently opened file",
 			description: "Currently opened file",
 		},
 		},
 		{
 		{
@@ -89,6 +189,11 @@ describe("getContextMenuOptions", () => {
 		},
 		},
 	]
 	]
 
 
+	const mockSearchResults: SearchResult[] = [
+		{ path: "/Users/test/project/src/search result spaces.ts", type: "file", label: "search result spaces.ts" },
+		{ path: "/Users/test/project/assets/", type: "folder", label: "assets/" },
+	]
+
 	it("should return all option types for empty query", () => {
 	it("should return all option types for empty query", () => {
 		const result = getContextMenuOptions("", "", null, [])
 		const result = getContextMenuOptions("", "", null, [])
 		expect(result).toHaveLength(6)
 		expect(result).toHaveLength(6)
@@ -108,7 +213,7 @@ describe("getContextMenuOptions", () => {
 		expect(result.map((item) => item.type)).toContain(ContextMenuOptionType.File)
 		expect(result.map((item) => item.type)).toContain(ContextMenuOptionType.File)
 		expect(result.map((item) => item.type)).toContain(ContextMenuOptionType.OpenedFile)
 		expect(result.map((item) => item.type)).toContain(ContextMenuOptionType.OpenedFile)
 		expect(result.map((item) => item.value)).toContain("src/test.ts")
 		expect(result.map((item) => item.value)).toContain("src/test.ts")
-		expect(result.map((item) => item.value)).toContain("src/opened.ts")
+		expect(result.map((item) => item.value)).toContain("src/open file.ts")
 	})
 	})
 
 
 	it("should match git commands", () => {
 	it("should match git commands", () => {
@@ -345,6 +450,61 @@ describe("getContextMenuOptions", () => {
 		// Should return NoResults since it won't match anything
 		// Should return NoResults since it won't match anything
 		expect(result[0].type).toBe(ContextMenuOptionType.NoResults)
 		expect(result[0].type).toBe(ContextMenuOptionType.NoResults)
 	})
 	})
+
+	// --- Tests for Escaped Spaces (Focus on how paths are presented) ---
+	it("should return search results with correct labels/descriptions (no escaping needed here)", () => {
+		const options = getContextMenuOptions("@search", "search", null, mockQueryItems, mockSearchResults)
+		const fileResult = options.find((o) => o.label === "search result spaces.ts")
+		expect(fileResult).toBeDefined()
+		// Value should be the normalized path, description might be the same or label
+		expect(fileResult?.value).toBe("/Users/test/project/src/search result spaces.ts")
+		expect(fileResult?.description).toBe("/Users/test/project/src/search result spaces.ts") // Check current implementation
+		expect(fileResult?.label).toBe("search result spaces.ts")
+		// Crucially, no backslashes should be in label/description here
+		expect(fileResult?.label).not.toContain("\\")
+		expect(fileResult?.description).not.toContain("\\")
+	})
+
+	it("should return query items (like opened files) with correct labels/descriptions", () => {
+		const options = getContextMenuOptions("open", "@open", null, mockQueryItems, [])
+		const openedFile = options.find((o) => o.label === "open file.ts")
+		expect(openedFile).toBeDefined()
+		expect(openedFile?.value).toBe("src/open file.ts")
+		// Check label/description based on current implementation
+		expect(openedFile?.label).toBe("open file.ts")
+		// No backslashes expected in display values
+		expect(openedFile?.label).not.toContain("\\")
+	})
+
+	it("should handle formatting of search results without escaping spaces in display", () => {
+		// Create a search result with spaces in the path
+		const searchResults: SearchResult[] = [
+			{ path: "/path/with spaces/file.txt", type: "file", label: "file with spaces.txt" },
+		]
+
+		// The formatting happens in getContextMenuOptions when converting search results to menu items
+		const formattedItems = getContextMenuOptions("spaces", "@spaces", null, [], searchResults)
+
+		// Verify we get some results back that aren't "No Results"
+		expect(formattedItems.length).toBeGreaterThan(0)
+		expect(formattedItems[0].type !== ContextMenuOptionType.NoResults).toBeTruthy()
+
+		// The main thing we want to verify is that no backslashes show up in any display fields
+		// This is the core UI behavior we want to test - spaces should not be escaped in display text
+		formattedItems.forEach((item) => {
+			// Some items might not have labels or descriptions, so check conditionally
+			if (item.label) {
+				// Verify the label doesn't contain any escaped spaces
+				expect(item.label.indexOf("\\")).toBe(-1)
+			}
+			if (item.description) {
+				// Verify the description doesn't contain any escaped spaces
+				expect(item.description.indexOf("\\")).toBe(-1)
+			}
+		})
+	})
+
+	// Add more tests for filtering, fuzzy search interaction if needed
 })
 })
 
 
 describe("shouldShowContextMenu", () => {
 describe("shouldShowContextMenu", () => {
@@ -372,4 +532,15 @@ describe("shouldShowContextMenu", () => {
 		// Position cursor at the end to test the full word
 		// Position cursor at the end to test the full word
 		expect(shouldShowContextMenu("@problems", 9)).toBe(true)
 		expect(shouldShowContextMenu("@problems", 9)).toBe(true)
 	})
 	})
+
+	// --- Tests for Escaped Spaces ---
+	it("should return true when typing path with escaped spaces", () => {
+		expect(shouldShowContextMenu("@/path/to/file\\ ", 17)).toBe(true) // Cursor after escaped space
+		expect(shouldShowContextMenu("@/path/to/file\\ with\\ spaces", 28)).toBe(true) // Cursor within path after escaped spaces
+	})
+
+	it("should return false if an unescaped space exists after @", () => {
+		// This case means the regex wouldn't match anyway, but confirms context menu logic
+		expect(shouldShowContextMenu("@/path/with space", 13)).toBe(false) // Cursor after unescaped space
+	})
 })
 })

+ 85 - 39
webview-ui/src/utils/__tests__/path-mentions.test.ts

@@ -1,60 +1,106 @@
-import { convertToMentionPath } from "../path-mentions"
+import { escapeSpaces, convertToMentionPath } from "../path-mentions"
 
 
-describe("path-mentions", () => {
-	describe("convertToMentionPath", () => {
-		it("should convert an absolute path to a mention path when it starts with cwd", () => {
-			// Windows-style paths
-			expect(convertToMentionPath("C:\\Users\\user\\project\\file.txt", "C:\\Users\\user\\project")).toBe(
-				"@/file.txt",
-			)
+describe("Path Mentions Utilities", () => {
+	describe("escapeSpaces", () => {
+		it("should replace spaces with escaped spaces", () => {
+			expect(escapeSpaces("file with spaces.txt")).toBe("file\\ with\\ spaces.txt")
+			expect(escapeSpaces("/path/to/another file/")).toBe("/path/to/another\\ file/")
+			expect(escapeSpaces("single space")).toBe("single\\ space")
+		})
 
 
-			// Unix-style paths
-			expect(convertToMentionPath("/Users/user/project/file.txt", "/Users/user/project")).toBe("@/file.txt")
+		it("should handle paths without spaces", () => {
+			expect(escapeSpaces("file_without_spaces.txt")).toBe("file_without_spaces.txt")
+			expect(escapeSpaces("/path/to/normal/file")).toBe("/path/to/normal/file")
 		})
 		})
 
 
-		it("should handle paths with trailing slashes in cwd", () => {
-			expect(convertToMentionPath("/Users/user/project/file.txt", "/Users/user/project/")).toBe("@/file.txt")
+		it("should handle multiple spaces", () => {
+			expect(escapeSpaces("a b c d.txt")).toBe("a\\ b\\ c\\ d.txt")
 		})
 		})
 
 
-		it("should be case-insensitive when matching paths", () => {
-			expect(convertToMentionPath("/Users/User/Project/file.txt", "/users/user/project")).toBe("@/file.txt")
+		it("should handle leading/trailing spaces", () => {
+			expect(escapeSpaces(" leading space")).toBe("\\ leading\\ space")
+			expect(escapeSpaces("trailing space ")).toBe("trailing\\ space\\ ")
 		})
 		})
 
 
-		it("should return the original path when cwd is not provided", () => {
-			expect(convertToMentionPath("/Users/user/project/file.txt")).toBe("/Users/user/project/file.txt")
+		it("should handle empty string", () => {
+			expect(escapeSpaces("")).toBe("")
 		})
 		})
 
 
-		it("should return the original path when it does not start with cwd", () => {
-			expect(convertToMentionPath("/Users/other/project/file.txt", "/Users/user/project")).toBe(
-				"/Users/other/project/file.txt",
-			)
+		it("should not affect already escaped spaces", () => {
+			// This function assumes input spaces are not already escaped
+			// The function will re-escape the backslashes, resulting in double-escaped spaces
+			expect(escapeSpaces("file\\ with\\ spaces.txt")).toBe("file\\\\ with\\\\ spaces.txt")
 		})
 		})
 
 
-		it("should normalize backslashes to forward slashes", () => {
-			expect(convertToMentionPath("C:\\Users\\user\\project\\subdir\\file.txt", "C:\\Users\\user\\project")).toBe(
-				"@/subdir/file.txt",
-			)
+		it("should not escape other characters", () => {
+			expect(escapeSpaces("path/with/slashes")).toBe("path/with/slashes")
+			expect(escapeSpaces("file-with-hyphens.txt")).toBe("file-with-hyphens.txt")
 		})
 		})
+	})
+
+	describe("convertToMentionPath", () => {
+		const MOCK_CWD_POSIX = "/Users/test/project"
+		const MOCK_CWD_WIN = "C:\\Users\\test\\project"
 
 
-		it("should handle nested paths correctly", () => {
-			expect(convertToMentionPath("/Users/user/project/nested/deeply/file.txt", "/Users/user/project")).toBe(
-				"@/nested/deeply/file.txt",
-			)
+		it("should convert absolute posix path within cwd to relative mention path and escape spaces", () => {
+			const absPath = "/Users/test/project/src/file with spaces.ts"
+			expect(convertToMentionPath(absPath, MOCK_CWD_POSIX)).toBe("@/src/file\\ with\\ spaces.ts")
 		})
 		})
 
 
-		it("should strip file:// protocol from paths if present", () => {
-			// Without cwd
-			expect(convertToMentionPath("file:///Users/user/project/file.txt")).toBe("/Users/user/project/file.txt")
+		it("should convert absolute windows path within cwd to relative mention path and escape spaces", () => {
+			const absPath = "C:\\Users\\test\\project\\src\\file with spaces.ts"
+			expect(convertToMentionPath(absPath, MOCK_CWD_WIN)).toBe("@/src/file\\ with\\ spaces.ts")
+		})
+
+		it("should handle paths already relative to cwd (though input is usually absolute)", () => {
+			const relPath = "src/another file.js" // Assuming this might be passed somehow
+			// It won't match startsWith(cwd), so it should return the original path (but normalized)
+			expect(convertToMentionPath(relPath, MOCK_CWD_POSIX)).toBe("src/another file.js")
+		})
 
 
-			// With cwd - should strip protocol and then apply mention path logic
-			expect(convertToMentionPath("file:///Users/user/project/file.txt", "/Users/user/project")).toBe(
-				"@/file.txt",
-			)
+		it("should handle paths outside cwd by returning the original path (normalized)", () => {
+			const absPath = "/Users/other/file.txt"
+			expect(convertToMentionPath(absPath, MOCK_CWD_POSIX)).toBe("/Users/other/file.txt")
+			// Since we can't control the implementation of path normalization in this test,
+			// let's accept either form of path separators (/ or \) for the Windows path test
+			const winPath = "D:\\another\\folder\\file.txt"
+			const result = convertToMentionPath(winPath, MOCK_CWD_WIN)
+			// Check that the path was returned without being converted to a mention
+			expect(result.startsWith("@")).toBe(false)
+			// Check the path contains the expected components regardless of separator
+			expect(result.toLowerCase()).toContain("d:")
+			expect(result.toLowerCase()).toContain("another")
+			expect(result.toLowerCase()).toContain("folder")
+			expect(result.toLowerCase()).toContain("file.txt")
+		})
+
+		it("should handle paths with no spaces correctly", () => {
+			const absPath = "/Users/test/project/src/normal.ts"
+			expect(convertToMentionPath(absPath, MOCK_CWD_POSIX)).toBe("@/src/normal.ts")
+		})
+
+		it("should add leading slash if missing after cwd removal", () => {
+			const absPath = "/Users/test/projectfile.txt" // Edge case: file directly in project root
+			const cwd = "/Users/test/project"
+			expect(convertToMentionPath(absPath, cwd)).toBe("@/file.txt") // Should still add '/'
+		})
+
+		it("should handle cwd with trailing slash", () => {
+			const absPath = "/Users/test/project/src/file with spaces.ts"
+			const cwdWithSlash = MOCK_CWD_POSIX + "/"
+			expect(convertToMentionPath(absPath, cwdWithSlash)).toBe("@/src/file\\ with\\ spaces.ts")
+		})
+
+		it("should handle case-insensitive matching for cwd", () => {
+			const absPath = "/users/test/project/src/file with spaces.ts" // Lowercase path
+			expect(convertToMentionPath(absPath, MOCK_CWD_POSIX)).toBe("@/src/file\\ with\\ spaces.ts") // Should still match uppercase CWD
+			const absPathUpper = "/USERS/TEST/PROJECT/src/file.ts" // Uppercase path
+			expect(convertToMentionPath(absPathUpper, MOCK_CWD_POSIX.toLowerCase())).toBe("@/src/file.ts") // Should match lowercase CWD
+		})
 
 
-			// With Windows paths
-			expect(convertToMentionPath("file://C:/Users/user/project/file.txt", "C:/Users/user/project")).toBe(
-				"@/file.txt",
-			)
+		it("should return original path if cwd is not provided", () => {
+			const absPath = "/Users/test/project/src/file with spaces.ts"
+			expect(convertToMentionPath(absPath, undefined)).toBe("/Users/test/project/src/file with spaces.ts")
 		})
 		})
 	})
 	})
 })
 })

+ 33 - 9
webview-ui/src/utils/context-mentions.ts

@@ -2,6 +2,7 @@ import { mentionRegex } from "@roo/shared/context-mentions"
 import { Fzf } from "fzf"
 import { Fzf } from "fzf"
 import { ModeConfig } from "@roo/shared/modes"
 import { ModeConfig } from "@roo/shared/modes"
 import * as path from "path"
 import * as path from "path"
+import { escapeSpaces } from "./path-mentions"
 
 
 export interface SearchResult {
 export interface SearchResult {
 	path: string
 	path: string
@@ -27,6 +28,15 @@ export function insertMention(
 	// Find the position of the last '@' symbol before the cursor
 	// Find the position of the last '@' symbol before the cursor
 	const lastAtIndex = beforeCursor.lastIndexOf("@")
 	const lastAtIndex = beforeCursor.lastIndexOf("@")
 
 
+	// Process the value - escape spaces if it's a file path
+	let processedValue = value
+	if (value && value.startsWith("/")) {
+		// Only escape if the path contains spaces that aren't already escaped
+		if (value.includes(" ") && !value.includes("\\ ")) {
+			processedValue = escapeSpaces(value)
+		}
+	}
+
 	let newValue: string
 	let newValue: string
 	let mentionIndex: number
 	let mentionIndex: number
 
 
@@ -38,11 +48,11 @@ export function insertMention(
 		const afterCursorContent = /^[a-zA-Z0-9\s]*$/.test(afterCursor)
 		const afterCursorContent = /^[a-zA-Z0-9\s]*$/.test(afterCursor)
 			? afterCursor.replace(/^[^\s]*/, "")
 			? afterCursor.replace(/^[^\s]*/, "")
 			: afterCursor
 			: afterCursor
-		newValue = beforeMention + "@" + value + " " + afterCursorContent
+		newValue = beforeMention + "@" + processedValue + " " + afterCursorContent
 		mentionIndex = lastAtIndex
 		mentionIndex = lastAtIndex
 	} else {
 	} else {
 		// If there's no '@' symbol, insert the mention at the cursor position
 		// If there's no '@' symbol, insert the mention at the cursor position
-		newValue = beforeCursor + "@" + value + " " + afterCursor
+		newValue = beforeCursor + "@" + processedValue + " " + afterCursor
 		mentionIndex = position
 		mentionIndex = position
 	}
 	}
 
 
@@ -58,8 +68,11 @@ export function removeMention(text: string, position: number): { newText: string
 
 
 	if (matchEnd) {
 	if (matchEnd) {
 		// If we're at the end of a mention, remove it
 		// If we're at the end of a mention, remove it
-		const newText = text.slice(0, position - matchEnd[0].length) + afterCursor.replace(" ", "") // removes the first space after the mention
-		const newPosition = position - matchEnd[0].length
+		// Remove the mention and the first space that follows it
+		const mentionLength = matchEnd[0].length
+		// Remove the mention and one space after it if it exists
+		const newText = text.slice(0, position - mentionLength) + afterCursor.replace(/^\s/, "")
+		const newPosition = position - mentionLength
 		return { newText, newPosition }
 		return { newText, newPosition }
 	}
 	}
 
 
@@ -236,13 +249,21 @@ export function getContextMenuOptions(
 
 
 	// Convert search results to queryItems format
 	// Convert search results to queryItems format
 	const searchResultItems = dynamicSearchResults.map((result) => {
 	const searchResultItems = dynamicSearchResults.map((result) => {
-		const formattedPath = result.path.startsWith("/") ? result.path : `/${result.path}`
+		// Ensure paths start with / for consistency
+		let formattedPath = result.path.startsWith("/") ? result.path : `/${result.path}`
+
+		// For display purposes, we don't escape spaces in the label or description
+		const displayPath = formattedPath
+		const displayName = result.label || path.basename(result.path)
+
+		// We don't need to escape spaces here because the insertMention function
+		// will handle that when the user selects a suggestion
 
 
 		return {
 		return {
 			type: result.type === "folder" ? ContextMenuOptionType.Folder : ContextMenuOptionType.File,
 			type: result.type === "folder" ? ContextMenuOptionType.Folder : ContextMenuOptionType.File,
 			value: formattedPath,
 			value: formattedPath,
-			label: result.label || path.basename(result.path),
-			description: formattedPath,
+			label: displayName,
+			description: displayPath,
 		}
 		}
 	})
 	})
 
 
@@ -285,8 +306,11 @@ export function shouldShowContextMenu(text: string, position: number): boolean {
 
 
 	const textAfterAt = beforeCursor.slice(atIndex + 1)
 	const textAfterAt = beforeCursor.slice(atIndex + 1)
 
 
-	// Check if there's any whitespace after the '@'
-	if (/\s/.test(textAfterAt)) return false
+	// Check if there's any unescaped whitespace after the '@'
+	// We need to check for whitespace that isn't preceded by a backslash
+	// Using a negative lookbehind to ensure the space isn't escaped
+	const hasUnescapedSpace = /(?<!\\)\s/.test(textAfterAt)
+	if (hasUnescapedSpace) return false
 
 
 	// Don't show the menu if it's clearly a URL
 	// Don't show the menu if it's clearly a URL
 	if (textAfterAt.toLowerCase().startsWith("http")) {
 	if (textAfterAt.toLowerCase().startsWith("http")) {

+ 18 - 2
webview-ui/src/utils/path-mentions.ts

@@ -2,10 +2,21 @@
  * Utilities for handling path-related operations in mentions
  * Utilities for handling path-related operations in mentions
  */
  */
 
 
+/**
+ * Escapes spaces in a path with backslashes
+ *
+ * @param path The path to escape
+ * @returns A path with spaces escaped
+ */
+export function escapeSpaces(path: string): string {
+	return path.replace(/ /g, "\\ ")
+}
+
 /**
 /**
  * Converts an absolute path to a mention-friendly path
  * Converts an absolute path to a mention-friendly path
  * If the provided path starts with the current working directory,
  * If the provided path starts with the current working directory,
  * it's converted to a relative path prefixed with @
  * it's converted to a relative path prefixed with @
+ * Spaces in the path are escaped with backslashes
  *
  *
  * @param path The path to convert
  * @param path The path to convert
  * @param cwd The current working directory
  * @param cwd The current working directory
@@ -55,9 +66,14 @@ export function convertToMentionPath(path: string, cwd?: string): string {
 	const lowerCwd = normalizedCwd.toLowerCase()
 	const lowerCwd = normalizedCwd.toLowerCase()
 
 
 	if (lowerPath.startsWith(lowerCwd)) {
 	if (lowerPath.startsWith(lowerCwd)) {
-		const relativePath = normalizedPath.substring(normalizedCwd.length)
+		let relativePath = normalizedPath.substring(normalizedCwd.length)
 		// Ensure there's a slash after the @ symbol when we create the mention path
 		// Ensure there's a slash after the @ symbol when we create the mention path
-		return "@" + (relativePath.startsWith("/") ? relativePath : "/" + relativePath)
+		relativePath = relativePath.startsWith("/") ? relativePath : "/" + relativePath
+
+		// Escape any spaces in the path with backslashes
+		const escapedRelativePath = escapeSpaces(relativePath)
+
+		return "@" + escapedRelativePath
 	}
 	}
 
 
 	return pathWithoutProtocol
 	return pathWithoutProtocol