Răsfoiți Sursa

Move context-mention file/folder search to the server (#1824)

Matt Rubens 9 luni în urmă
părinte
comite
f181da1454

+ 6 - 0
package-lock.json

@@ -32,6 +32,7 @@
 				"diff-match-patch": "^1.0.5",
 				"fast-deep-equal": "^3.1.3",
 				"fastest-levenshtein": "^1.0.16",
+				"fzf": "^0.5.2",
 				"get-folder-size": "^5.0.0",
 				"globby": "^14.0.2",
 				"i18next": "^24.2.2",
@@ -9326,6 +9327,11 @@
 				"url": "https://github.com/sponsors/ljharb"
 			}
 		},
+		"node_modules/fzf": {
+			"version": "0.5.2",
+			"resolved": "https://registry.npmjs.org/fzf/-/fzf-0.5.2.tgz",
+			"integrity": "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q=="
+		},
 		"node_modules/gauge": {
 			"version": "5.0.2",
 			"resolved": "https://registry.npmjs.org/gauge/-/gauge-5.0.2.tgz",

+ 1 - 0
package.json

@@ -345,6 +345,7 @@
 		"diff-match-patch": "^1.0.5",
 		"fast-deep-equal": "^3.1.3",
 		"fastest-levenshtein": "^1.0.16",
+		"fzf": "^0.5.2",
 		"get-folder-size": "^5.0.0",
 		"globby": "^14.0.2",
 		"i18next": "^24.2.2",

+ 41 - 0
src/core/webview/ClineProvider.ts

@@ -39,6 +39,7 @@ import { McpServerManager } from "../../services/mcp/McpServerManager"
 import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckpointService"
 import { BrowserSession } from "../../services/browser/BrowserSession"
 import { discoverChromeInstances } from "../../services/browser/browserDiscovery"
+import { searchWorkspaceFiles } from "../../services/search/file-search"
 import { fileExistsAtPath } from "../../utils/fs"
 import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound"
 import { playTts, setTtsEnabled, setTtsSpeed, stopTts } from "../../utils/tts"
@@ -1750,6 +1751,46 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 						}
 						break
 					}
+					case "searchFiles": {
+						const workspacePath = getWorkspacePath()
+
+						if (!workspacePath) {
+							// Handle case where workspace path is not available
+							await this.postMessageToWebview({
+								type: "fileSearchResults",
+								results: [],
+								requestId: message.requestId,
+								error: "No workspace path available",
+							})
+							break
+						}
+						try {
+							// Call file search service with query from message
+							const results = await searchWorkspaceFiles(
+								message.query || "",
+								workspacePath,
+								20, // Use default limit, as filtering is now done in the backend
+							)
+
+							// Send results back to webview
+							await this.postMessageToWebview({
+								type: "fileSearchResults",
+								results,
+								requestId: message.requestId,
+							})
+						} catch (error) {
+							const errorMessage = error instanceof Error ? error.message : String(error)
+
+							// Send error response to webview
+							await this.postMessageToWebview({
+								type: "fileSearchResults",
+								results: [],
+								error: errorMessage,
+								requestId: message.requestId,
+							})
+						}
+						break
+					}
 					case "saveApiConfiguration":
 						if (message.text && message.apiConfiguration) {
 							try {

+ 6 - 11
src/services/ripgrep/index.ts

@@ -4,6 +4,7 @@ import * as path from "path"
 import * as fs from "fs"
 import * as readline from "readline"
 import { RooIgnoreController } from "../../core/ignore/RooIgnoreController"
+import { fileExistsAtPath } from "../../utils/fs"
 /*
 This file provides functionality to perform regex searches on files using ripgrep.
 Inspired by: https://github.com/DiscreteTom/vscode-ripgrep-utils
@@ -71,11 +72,13 @@ const MAX_LINE_LENGTH = 500
 export function truncateLine(line: string, maxLength: number = MAX_LINE_LENGTH): string {
 	return line.length > maxLength ? line.substring(0, maxLength) + " [truncated...]" : line
 }
-
-async function getBinPath(vscodeAppRoot: string): Promise<string | undefined> {
+/**
+ * Get the path to the ripgrep binary within the VSCode installation
+ */
+export async function getBinPath(vscodeAppRoot: string): Promise<string | undefined> {
 	const checkPath = async (pkgFolder: string) => {
 		const fullPath = path.join(vscodeAppRoot, pkgFolder, binName)
-		return (await pathExists(fullPath)) ? fullPath : undefined
+		return (await fileExistsAtPath(fullPath)) ? fullPath : undefined
 	}
 
 	return (
@@ -86,14 +89,6 @@ async function getBinPath(vscodeAppRoot: string): Promise<string | undefined> {
 	)
 }
 
-async function pathExists(path: string): Promise<boolean> {
-	return new Promise((resolve) => {
-		fs.access(path, (err) => {
-			resolve(err === null)
-		})
-	})
-}
-
 async function execRipgrep(bin: string, args: string[]): Promise<string> {
 	return new Promise((resolve, reject) => {
 		const rgProcess = childProcess.spawn(bin, args)

+ 125 - 0
src/services/search/file-search.ts

@@ -0,0 +1,125 @@
+import * as vscode from "vscode"
+import * as path from "path"
+import * as fs from "fs"
+import * as childProcess from "child_process"
+import * as readline from "readline"
+import { Fzf } from "fzf"
+import { getBinPath } from "../ripgrep"
+
+async function executeRipgrepForFiles(
+	rgPath: string,
+	workspacePath: string,
+	limit: number = 5000,
+): Promise<{ path: string; type: "file" | "folder"; label?: string }[]> {
+	return new Promise((resolve, reject) => {
+		const args = [
+			"--files",
+			"--follow",
+			"-g",
+			"!**/node_modules/**",
+			"-g",
+			"!**/.git/**",
+			"-g",
+			"!**/out/**",
+			"-g",
+			"!**/dist/**",
+			workspacePath,
+		]
+
+		const rgProcess = childProcess.spawn(rgPath, args)
+		const rl = readline.createInterface({
+			input: rgProcess.stdout,
+			crlfDelay: Infinity,
+		})
+
+		const results: { path: string; type: "file" | "folder"; label?: string }[] = []
+		let count = 0
+
+		rl.on("line", (line) => {
+			if (count < limit) {
+				try {
+					const relativePath = path.relative(workspacePath, line)
+					results.push({
+						path: relativePath,
+						type: "file",
+						label: path.basename(relativePath),
+					})
+					count++
+				} catch (error) {
+					// Silently ignore errors processing individual paths
+				}
+			} else {
+				rl.close()
+				rgProcess.kill()
+			}
+		})
+
+		let errorOutput = ""
+		rgProcess.stderr.on("data", (data) => {
+			errorOutput += data.toString()
+		})
+
+		rl.on("close", () => {
+			if (errorOutput && results.length === 0) {
+				reject(new Error(`ripgrep process error: ${errorOutput}`))
+			} else {
+				resolve(results)
+			}
+		})
+
+		rgProcess.on("error", (error) => {
+			reject(new Error(`ripgrep process error: ${error.message}`))
+		})
+	})
+}
+
+export async function searchWorkspaceFiles(
+	query: string,
+	workspacePath: string,
+	limit: number = 20,
+): Promise<{ path: string; type: "file" | "folder"; label?: string }[]> {
+	try {
+		const vscodeAppRoot = vscode.env.appRoot
+		const rgPath = await getBinPath(vscodeAppRoot)
+
+		if (!rgPath) {
+			throw new Error("Could not find ripgrep binary")
+		}
+
+		const allFiles = await executeRipgrepForFiles(rgPath, workspacePath, 5000)
+
+		if (!query.trim()) {
+			return allFiles.slice(0, limit)
+		}
+
+		const searchItems = allFiles.map((file) => ({
+			original: file,
+			searchStr: `${file.path} ${file.label || ""}`,
+		}))
+
+		const fzf = new Fzf(searchItems, {
+			selector: (item) => item.searchStr,
+		})
+
+		const results = fzf
+			.find(query)
+			.slice(0, limit)
+			.map((result) => result.item.original)
+
+		const resultsWithDirectoryCheck = await Promise.all(
+			results.map(async (result) => {
+				const fullPath = path.join(workspacePath, result.path)
+				const isDirectory = fs.existsSync(fullPath) && fs.lstatSync(fullPath).isDirectory()
+
+				return {
+					...result,
+					type: isDirectory ? ("folder" as const) : ("file" as const),
+				}
+			}),
+		)
+
+		return resultsWithDirectoryCheck
+	} catch (error) {
+		return []
+	}
+}

+ 7 - 0
src/shared/ExtensionMessage.ts

@@ -56,6 +56,7 @@ export interface ExtensionMessage {
 		| "remoteBrowserEnabled"
 		| "ttsStart"
 		| "ttsStop"
+		| "fileSearchResults"
 	text?: string
 	action?:
 		| "chatButtonClicked"
@@ -92,6 +93,12 @@ export interface ExtensionMessage {
 	values?: Record<string, any>
 	requestId?: string
 	promptText?: string
+	results?: Array<{
+		path: string
+		type: "file" | "folder"
+		label?: string
+	}>
+	error?: string
 }
 
 export interface ApiConfigMeta {

+ 1 - 0
src/shared/WebviewMessage.ts

@@ -114,6 +114,7 @@ export interface WebviewMessage {
 		| "browserConnectionResult"
 		| "remoteBrowserEnabled"
 		| "language"
+		| "searchFiles"
 	text?: string
 	disabled?: boolean
 	askResponse?: ClineAskResponse

+ 44 - 3
webview-ui/src/components/chat/ChatTextArea.tsx

@@ -16,6 +16,7 @@ import {
 	insertMention,
 	removeMention,
 	shouldShowContextMenu,
+	SearchResult,
 } from "@/utils/context-mentions"
 import { convertToMentionPath } from "@/utils/path-mentions"
 import { SelectDropdown, DropdownOptionType, Button } from "@/components/ui"
@@ -64,6 +65,9 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 		const { filePaths, openedTabs, currentApiConfigName, listApiConfigMeta, customModes, cwd } = useExtensionState()
 		const [gitCommits, setGitCommits] = useState<any[]>([])
 		const [showDropdown, setShowDropdown] = useState(false)
+		const [fileSearchResults, setFileSearchResults] = useState<SearchResult[]>([])
+		const [searchLoading, setSearchLoading] = useState(false)
+		const [searchRequestId, setSearchRequestId] = useState<string>("")
 
 		// Close dropdown when clicking outside.
 		useEffect(() => {
@@ -76,7 +80,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 			return () => document.removeEventListener("mousedown", handleClickOutside)
 		}, [showDropdown])
 
-		// Handle enhanced prompt response.
+		// Handle enhanced prompt response and search results.
 		useEffect(() => {
 			const messageHandler = (event: MessageEvent) => {
 				const message = event.data
@@ -97,12 +101,17 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 					}))
 
 					setGitCommits(commits)
+				} else if (message.type === "fileSearchResults") {
+					setSearchLoading(false)
+					if (message.requestId === searchRequestId) {
+						setFileSearchResults(message.results || [])
+					}
 				}
 			}
 
 			window.addEventListener("message", messageHandler)
 			return () => window.removeEventListener("message", messageHandler)
-		}, [setInputValue])
+		}, [setInputValue, searchRequestId])
 
 		const [thumbnailsHeight, setThumbnailsHeight] = useState(0)
 		const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined)
@@ -275,6 +284,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 								searchQuery,
 								selectedType,
 								queryItems,
+								fileSearchResults,
 								getAllModes(customModes),
 							)
 							const optionsLength = options.length
@@ -310,6 +320,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 							searchQuery,
 							selectedType,
 							queryItems,
+							fileSearchResults,
 							getAllModes(customModes),
 						)[selectedMenuIndex]
 						if (
@@ -378,6 +389,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 				justDeletedSpaceAfterMention,
 				queryItems,
 				customModes,
+				fileSearchResults,
 			],
 		)
 
@@ -387,6 +399,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 				setIntendedCursorPosition(null) // Reset the state.
 			}
 		}, [inputValue, intendedCursorPosition])
+		// Ref to store the search timeout
+		const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
 
 		const handleInputChange = useCallback(
 			(e: React.ChangeEvent<HTMLTextAreaElement>) => {
@@ -408,8 +422,32 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 						const lastAtIndex = newValue.lastIndexOf("@", newCursorPosition - 1)
 						const query = newValue.slice(lastAtIndex + 1, newCursorPosition)
 						setSearchQuery(query)
+
+						// Send file search request if query is not empty
 						if (query.length > 0) {
 							setSelectedMenuIndex(0)
+							// Don't clear results until we have new ones
+							// This prevents flickering
+
+							// Clear any existing timeout
+							if (searchTimeoutRef.current) {
+								clearTimeout(searchTimeoutRef.current)
+							}
+
+							// Set a timeout to debounce the search requests
+							searchTimeoutRef.current = setTimeout(() => {
+								// Generate a request ID for this search
+								const reqId = Math.random().toString(36).substring(2, 9)
+								setSearchRequestId(reqId)
+								setSearchLoading(true)
+
+								// Send message to extension to search files
+								vscode.postMessage({
+									type: "searchFiles",
+									query: query,
+									requestId: reqId,
+								})
+							}, 200) // 200ms debounce
 						} else {
 							setSelectedMenuIndex(3) // Set to "File" option by default
 						}
@@ -417,9 +455,10 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 				} else {
 					setSearchQuery("")
 					setSelectedMenuIndex(-1)
+					setFileSearchResults([]) // Clear file search results
 				}
 			},
-			[setInputValue],
+			[setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading],
 		)
 
 		useEffect(() => {
@@ -675,6 +714,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 							selectedType={selectedType}
 							queryItems={queryItems}
 							modes={getAllModes(customModes)}
+							loading={searchLoading}
+							dynamicSearchResults={fileSearchResults}
 						/>
 					</div>
 				)}

+ 83 - 61
webview-ui/src/components/chat/ContextMenu.tsx

@@ -1,5 +1,10 @@
 import React, { useEffect, useMemo, useRef } from "react"
-import { ContextMenuOptionType, ContextMenuQueryItem, getContextMenuOptions } from "../../utils/context-mentions"
+import {
+	ContextMenuOptionType,
+	ContextMenuQueryItem,
+	getContextMenuOptions,
+	SearchResult,
+} from "../../utils/context-mentions"
 import { removeLeadingNonAlphanumeric } from "../common/CodeAccordian"
 import { ModeConfig } from "../../../../src/shared/modes"
 
@@ -12,6 +17,8 @@ interface ContextMenuProps {
 	selectedType: ContextMenuOptionType | null
 	queryItems: ContextMenuQueryItem[]
 	modes?: ModeConfig[]
+	loading?: boolean // New loading prop
+	dynamicSearchResults?: SearchResult[] // New dynamic search results prop
 }
 
 const ContextMenu: React.FC<ContextMenuProps> = ({
@@ -23,13 +30,14 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
 	selectedType,
 	queryItems,
 	modes,
+	loading = false,
+	dynamicSearchResults = [],
 }) => {
 	const menuRef = useRef<HTMLDivElement>(null)
 
-	const filteredOptions = useMemo(
-		() => getContextMenuOptions(searchQuery, selectedType, queryItems, modes),
-		[searchQuery, selectedType, queryItems, modes],
-	)
+	const filteredOptions = useMemo(() => {
+		return getContextMenuOptions(searchQuery, selectedType, queryItems, dynamicSearchResults, modes)
+	}, [searchQuery, selectedType, queryItems, dynamicSearchResults, modes])
 
 	useEffect(() => {
 		if (menuRef.current) {
@@ -175,71 +183,85 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
 					maxHeight: "200px",
 					overflowY: "auto",
 				}}>
-				{filteredOptions.map((option, index) => (
-					<div
-						key={`${option.type}-${option.value || index}`}
-						onClick={() => isOptionSelectable(option) && onSelect(option.type, option.value)}
-						style={{
-							padding: "8px 12px",
-							cursor: isOptionSelectable(option) ? "pointer" : "default",
-							color: "var(--vscode-dropdown-foreground)",
-							borderBottom: "1px solid var(--vscode-editorGroup-border)",
-							display: "flex",
-							alignItems: "center",
-							justifyContent: "space-between",
-							...(index === selectedIndex && isOptionSelectable(option)
-								? {
-										backgroundColor: "var(--vscode-list-activeSelectionBackground)",
-										color: "var(--vscode-list-activeSelectionForeground)",
-									}
-								: {}),
-						}}
-						onMouseEnter={() => isOptionSelectable(option) && setSelectedIndex(index)}>
+				{filteredOptions && filteredOptions.length > 0 ? (
+					filteredOptions.map((option, index) => (
 						<div
+							key={`${option.type}-${option.value || index}`}
+							onClick={() => isOptionSelectable(option) && onSelect(option.type, option.value)}
 							style={{
+								padding: "8px 12px",
+								cursor: isOptionSelectable(option) ? "pointer" : "default",
+								color: "var(--vscode-dropdown-foreground)",
+								borderBottom: "1px solid var(--vscode-editorGroup-border)",
 								display: "flex",
 								alignItems: "center",
-								flex: 1,
-								minWidth: 0,
-								overflow: "hidden",
-								paddingTop: 0,
-							}}>
-							{option.type !== ContextMenuOptionType.Mode && getIconForOption(option) && (
-								<i
-									className={`codicon codicon-${getIconForOption(option)}`}
-									style={{
-										marginRight: "6px",
-										flexShrink: 0,
-										fontSize: "14px",
-										marginTop: 0,
-									}}
-								/>
-							)}
-							{renderOptionContent(option)}
-						</div>
-						{(option.type === ContextMenuOptionType.File ||
-							option.type === ContextMenuOptionType.Folder ||
-							option.type === ContextMenuOptionType.Git) &&
-							!option.value && (
+								justifyContent: "space-between",
+								...(index === selectedIndex && isOptionSelectable(option)
+									? {
+											backgroundColor: "var(--vscode-list-activeSelectionBackground)",
+											color: "var(--vscode-list-activeSelectionForeground)",
+										}
+									: {}),
+							}}
+							onMouseEnter={() => isOptionSelectable(option) && setSelectedIndex(index)}>
+							<div
+								style={{
+									display: "flex",
+									alignItems: "center",
+									flex: 1,
+									minWidth: 0,
+									overflow: "hidden",
+									paddingTop: 0,
+								}}>
+								{option.type !== ContextMenuOptionType.Mode && getIconForOption(option) && (
+									<i
+										className={`codicon codicon-${getIconForOption(option)}`}
+										style={{
+											marginRight: "6px",
+											flexShrink: 0,
+											fontSize: "14px",
+											marginTop: 0,
+										}}
+									/>
+								)}
+								{renderOptionContent(option)}
+							</div>
+							{(option.type === ContextMenuOptionType.File ||
+								option.type === ContextMenuOptionType.Folder ||
+								option.type === ContextMenuOptionType.Git) &&
+								!option.value && (
+									<i
+										className="codicon codicon-chevron-right"
+										style={{ fontSize: "14px", flexShrink: 0, marginLeft: 8 }}
+									/>
+								)}
+							{(option.type === ContextMenuOptionType.Problems ||
+								option.type === ContextMenuOptionType.Terminal ||
+								((option.type === ContextMenuOptionType.File ||
+									option.type === ContextMenuOptionType.Folder ||
+									option.type === ContextMenuOptionType.OpenedFile ||
+									option.type === ContextMenuOptionType.Git) &&
+									option.value)) && (
 								<i
-									className="codicon codicon-chevron-right"
+									className="codicon codicon-add"
 									style={{ fontSize: "14px", flexShrink: 0, marginLeft: 8 }}
 								/>
 							)}
-						{(option.type === ContextMenuOptionType.Problems ||
-							option.type === ContextMenuOptionType.Terminal ||
-							((option.type === ContextMenuOptionType.File ||
-								option.type === ContextMenuOptionType.Folder ||
-								option.type === ContextMenuOptionType.OpenedFile ||
-								option.type === ContextMenuOptionType.Git) &&
-								option.value)) && (
-							<i
-								className="codicon codicon-add"
-								style={{ fontSize: "14px", flexShrink: 0, marginLeft: 8 }}
-							/>
-						)}
+						</div>
+					))
+				) : (
+					<div
+						style={{
+							padding: "12px",
+							display: "flex",
+							alignItems: "center",
+							justifyContent: "center",
+							color: "var(--vscode-foreground)",
+							opacity: 0.7,
+						}}>
+						<span>No results found</span>
 					</div>
-				))}
+				)}
 			</div>
 		</div>
 	)

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

@@ -131,8 +131,8 @@ describe("shouldShowContextMenu", () => {
 		expect(shouldShowContextMenu("Hello @http://test.com", 17)).toBe(false)
 	})
 
-	it("should return false for @problems", () => {
+	it("should return true for @problems", () => {
 		// Position cursor at the end to test the full word
-		expect(shouldShowContextMenu("@problems", 9)).toBe(false)
+		expect(shouldShowContextMenu("@problems", 9)).toBe(true)
 	})
 })

+ 46 - 13
webview-ui/src/utils/context-mentions.ts

@@ -1,7 +1,13 @@
 import { mentionRegex } from "../../../src/shared/context-mentions"
 import { Fzf } from "fzf"
 import { ModeConfig } from "../../../src/shared/modes"
+import * as path from "path"
 
+export interface SearchResult {
+	path: string
+	type: "file" | "folder"
+	label?: string
+}
 export function insertMention(
 	text: string,
 	position: number,
@@ -80,6 +86,7 @@ export function getContextMenuOptions(
 	query: string,
 	selectedType: ContextMenuOptionType | null = null,
 	queryItems: ContextMenuQueryItem[],
+	dynamicSearchResults: SearchResult[] = [],
 	modes?: ModeConfig[],
 ): ContextMenuQueryItem[] {
 	// Handle slash commands for modes
@@ -203,7 +210,34 @@ export function getContextMenuOptions(
 		}
 	}
 
-	// Create searchable strings array for fzf
+	if (dynamicSearchResults.length > 0) {
+		// Convert search results to queryItems format
+		const searchResultItems = dynamicSearchResults.map((result) => {
+			const formattedPath = result.path.startsWith("/") ? result.path : `/${result.path}`
+
+			return {
+				type: result.type === "folder" ? ContextMenuOptionType.Folder : ContextMenuOptionType.File,
+				value: formattedPath,
+				label: result.label || path.basename(result.path),
+				description: formattedPath,
+			}
+		})
+
+		const allItems = [...suggestions, ...searchResultItems]
+
+		// Remove duplicates
+		const seen = new Set()
+		const deduped = allItems.filter((item) => {
+			const key = `${item.type}-${item.value}`
+			if (seen.has(key)) return false
+			seen.add(key)
+			return true
+		})
+
+		return deduped
+	}
+
+	// Fallback to original static filtering if no dynamic results
 	const searchableItems = queryItems.map((item) => ({
 		original: item,
 		searchStr: [item.value, item.label, item.description].filter(Boolean).join(" "),
@@ -257,26 +291,25 @@ export function shouldShowContextMenu(text: string, position: number): boolean {
 	if (text.startsWith("/")) {
 		return position <= text.length && !text.includes(" ")
 	}
-
 	const beforeCursor = text.slice(0, position)
 	const atIndex = beforeCursor.lastIndexOf("@")
 
-	if (atIndex === -1) return false
+	if (atIndex === -1) {
+		return false
+	}
 
 	const textAfterAt = beforeCursor.slice(atIndex + 1)
 
-	// Check if there's any whitespace after the '@'
-	if (/\s/.test(textAfterAt)) return false
-
-	// Don't show the menu if it's a URL
-	if (textAfterAt.toLowerCase().startsWith("http")) return false
-
-	// Don't show the menu if it's a problems or terminal
-	if (textAfterAt.toLowerCase().startsWith("problems") || textAfterAt.toLowerCase().startsWith("terminal"))
+	// Don't show the menu if it's clearly a URL
+	if (textAfterAt.toLowerCase().startsWith("http")) {
 		return false
+	}
 
-	// NOTE: it's okay that menu shows when there's trailing punctuation since user could be inputting a path with marks
+	// If there's a space after @, don't show the menu (normal @ mention)
+	if (textAfterAt.indexOf(" ") === 0) {
+		return false
+	}
 
-	// Show the menu if there's just '@' or '@' followed by some text (but not a URL)
+	// Show menu in all other cases
 	return true
 }