diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index fb105056b55c..72842d37bcce 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -89,6 +89,10 @@ export const ChatTextArea = forwardRef( commands, } = useExtensionState() + // Detect if we're on Mac for modifier key detection + const isMac = typeof navigator !== "undefined" && navigator.platform.toUpperCase().indexOf("MAC") >= 0 + const [isModifierPressed, setIsModifierPressed] = useState(false) + // Find the ID and display text for the currently selected API configuration. const { currentConfigId, displayName } = useMemo(() => { const currentConfig = listApiConfigMeta?.find((config) => config.name === currentApiConfigName) @@ -221,6 +225,47 @@ export const ChatTextArea = forwardRef( setInputValue, }) + // Add global event listeners for modifier key detection + useEffect(() => { + const handleGlobalKeyDown = (e: KeyboardEvent) => { + if ((isMac && e.metaKey) || (!isMac && e.ctrlKey)) { + setIsModifierPressed(true) + } + } + + const handleGlobalKeyUp = (e: KeyboardEvent) => { + if ((isMac && !e.metaKey) || (!isMac && !e.ctrlKey)) { + setIsModifierPressed(false) + } + } + + document.addEventListener("keydown", handleGlobalKeyDown) + document.addEventListener("keyup", handleGlobalKeyUp) + + return () => { + document.removeEventListener("keydown", handleGlobalKeyDown) + document.removeEventListener("keyup", handleGlobalKeyUp) + } + }, [isMac]) + + // Handle clicks on mentions in the highlight layer + const handleMentionClick = useCallback( + (e: MouseEvent) => { + if (!isModifierPressed) return + + const target = e.target as HTMLElement + if (target.tagName === "MARK" && target.classList.contains("mention-context-textarea-highlight")) { + const mentionText = target.textContent + if (mentionText) { + // Remove @ symbol if present and send to VSCode + const cleanText = mentionText.startsWith("@") ? mentionText.slice(1) : mentionText + vscode.postMessage({ type: "openMention", text: cleanText }) + } + } + }, + [isModifierPressed], + ) + // Fetch git commits when Git is selected or when typing a hash. useEffect(() => { if (selectedType === ContextMenuOptionType.Git || /^[a-f0-9]+$/i.test(searchQuery)) { @@ -722,11 +767,17 @@ export const ChatTextArea = forwardRef( return commands?.some((cmd) => cmd.name === commandName) || false } + // Determine the class to use based on modifier key state + const mentionClass = `mention-context-textarea-highlight${isModifierPressed ? " clickable" : ""}` + const tooltipText = isModifierPressed + ? `${isMac ? "Cmd" : "Ctrl"} + Click to open` + : `Hold ${isMac ? "Cmd" : "Ctrl"} + Click to open` + // Process the text to highlight mentions and valid commands let processedText = text .replace(/\n$/, "\n\n") .replace(/[<>&]/g, (c) => ({ "<": "<", ">": ">", "&": "&" })[c] || c) - .replace(mentionRegexGlobal, '$&') + .replace(mentionRegexGlobal, `$&`) // Custom replacement for commands - only highlight valid ones processedText = processedText.replace(commandRegexGlobal, (match, commandName) => { @@ -738,10 +789,10 @@ export const ChatTextArea = forwardRef( if (startsWithSpace) { // Keep the space but only highlight the command part - return ` ${commandPart}` + return ` ${commandPart}` } else { // Highlight the entire command (starts at beginning of line) - return `${commandPart}` + return `${commandPart}` } } return match // Return unhighlighted if command is not valid @@ -751,12 +802,23 @@ export const ChatTextArea = forwardRef( highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft - }, [commands]) + }, [commands, isModifierPressed, isMac]) useLayoutEffect(() => { updateHighlights() }, [inputValue, updateHighlights]) + // Add click event listener to highlight layer for mention clicks + useEffect(() => { + const highlightLayer = highlightLayerRef.current + if (highlightLayer) { + highlightLayer.addEventListener("click", handleMentionClick) + return () => { + highlightLayer.removeEventListener("click", handleMentionClick) + } + } + }, [handleMentionClick]) + const updateCursorPosition = useCallback(() => { if (textAreaRef.current) { setCursorPosition(textAreaRef.current.selectionStart) diff --git a/webview-ui/src/index.css b/webview-ui/src/index.css index 6f23892ced31..840ad140aa74 100644 --- a/webview-ui/src/index.css +++ b/webview-ui/src/index.css @@ -379,22 +379,73 @@ vscode-dropdown::part(listbox) { /* Context mentions */ -.mention-context-textarea-highlight { +.mention-context-highlight { background-color: color-mix(in srgb, var(--vscode-badge-foreground) 30%, transparent); border-radius: 3px; - box-shadow: 0 0 0 0.5px color-mix(in srgb, var(--vscode-badge-foreground) 30%, transparent); - color: transparent; + transition: all 0.2s ease-in-out; } -.mention-context-highlight { - background-color: color-mix(in srgb, var(--vscode-badge-foreground) 30%, transparent); - border-radius: 3px; +.mention-context-highlight:hover { + background-color: color-mix(in srgb, var(--vscode-badge-foreground) 45%, transparent); + transform: translateY(-1px); + box-shadow: 0 2px 4px color-mix(in srgb, var(--vscode-badge-foreground) 20%, transparent); +} + +.mention-context-highlight.clickable { + cursor: pointer; +} + +.mention-context-highlight.clickable:hover { + background-color: color-mix(in srgb, var(--vscode-textLink-foreground) 40%, transparent); + border: 1px solid color-mix(in srgb, var(--vscode-textLink-foreground) 60%, transparent); } .mention-context-highlight-with-shadow { background-color: color-mix(in srgb, var(--vscode-badge-foreground) 30%, transparent); border-radius: 3px; box-shadow: 0 0 0 0.5px color-mix(in srgb, var(--vscode-badge-foreground) 30%, transparent); + transition: all 0.2s ease-in-out; +} + +.mention-context-highlight-with-shadow:hover { + background-color: color-mix(in srgb, var(--vscode-badge-foreground) 45%, transparent); + transform: translateY(-1px); + box-shadow: 0 2px 4px color-mix(in srgb, var(--vscode-badge-foreground) 20%, transparent); +} + +.mention-context-highlight-with-shadow.clickable { + cursor: pointer; +} + +.mention-context-highlight-with-shadow.clickable:hover { + background-color: color-mix(in srgb, var(--vscode-textLink-foreground) 40%, transparent); + border: 1px solid color-mix(in srgb, var(--vscode-textLink-foreground) 60%, transparent); + box-shadow: 0 2px 6px color-mix(in srgb, var(--vscode-textLink-foreground) 30%, transparent); +} + +.mention-context-textarea-highlight { + background-color: color-mix(in srgb, var(--vscode-badge-foreground) 30%, transparent); + border-radius: 3px; + transition: all 0.2s ease-in-out; + cursor: default; + pointer-events: none; +} + +.mention-context-textarea-highlight:hover { + background-color: color-mix(in srgb, var(--vscode-badge-foreground) 45%, transparent); + transform: translateY(-1px); + box-shadow: 0 2px 4px color-mix(in srgb, var(--vscode-badge-foreground) 20%, transparent); +} + +.mention-context-textarea-highlight.clickable { + cursor: pointer; + pointer-events: auto; +} + +.mention-context-textarea-highlight.clickable:hover { + background-color: color-mix(in srgb, var(--vscode-textLink-foreground) 40%, transparent); + border: 1px solid color-mix(in srgb, var(--vscode-textLink-foreground) 60%, transparent); + box-shadow: 0 2px 4px color-mix(in srgb, var(--vscode-textLink-foreground) 30%, transparent); } /**