diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index ddd7e7f60e5..1b0ca2e9637 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -714,15 +714,41 @@ const ChatTextArea = forwardRef( const text = textAreaRef.current.value - highlightLayerRef.current.innerHTML = text + // Helper function to check if a command is valid + const isValidCommand = (commandName: string): boolean => { + return commands?.some((cmd) => cmd.name === commandName) || false + } + + // Process the text to highlight mentions and valid commands + let processedText = text .replace(/\n$/, "\n\n") .replace(/[<>&]/g, (c) => ({ "<": "<", ">": ">", "&": "&" })[c] || c) .replace(mentionRegexGlobal, '$&') - .replace(commandRegexGlobal, '$&') + + // Custom replacement for commands - only highlight valid ones + processedText = processedText.replace(commandRegexGlobal, (match, commandName) => { + // Only highlight if the command exists in the valid commands list + if (isValidCommand(commandName)) { + // Check if the match starts with a space + const startsWithSpace = match.startsWith(" ") + const commandPart = `/${commandName}` + + if (startsWithSpace) { + // Keep the space but only highlight the command part + return ` ${commandPart}` + } else { + // Highlight the entire command (starts at beginning of line) + return `${commandPart}` + } + } + return match // Return unhighlighted if command is not valid + }) + + highlightLayerRef.current.innerHTML = processedText highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft - }, []) + }, [commands]) useLayoutEffect(() => { updateHighlights() @@ -973,6 +999,7 @@ const ChatTextArea = forwardRef( )}>
{ }) }) + describe("slash command highlighting", () => { + const mockCommands = [ + { name: "setup", source: "project", description: "Setup the project" }, + { name: "deploy", source: "global", description: "Deploy the application" }, + { name: "test-command", source: "project", description: "Test command with dash" }, + ] + + beforeEach(() => { + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + taskHistory: [], + cwd: "/test/workspace", + commands: mockCommands, + }) + }) + + it("should highlight valid slash commands", () => { + const { getByTestId } = render() + + const highlightLayer = getByTestId("highlight-layer") + expect(highlightLayer).toBeInTheDocument() + + // The highlighting is applied via innerHTML, so we need to check the content + // The valid command "/setup" should be highlighted + expect(highlightLayer.innerHTML).toContain('/setup') + }) + + it("should not highlight invalid slash commands", () => { + const { getByTestId } = render() + + const highlightLayer = getByTestId("highlight-layer") + expect(highlightLayer).toBeInTheDocument() + + // The invalid command "/invalid" should not be highlighted + expect(highlightLayer.innerHTML).not.toContain( + '/invalid', + ) + // But it should still contain the text without highlighting + expect(highlightLayer.innerHTML).toContain("/invalid") + }) + + it("should highlight only the command portion, not arguments", () => { + const { getByTestId } = render() + + const highlightLayer = getByTestId("highlight-layer") + expect(highlightLayer).toBeInTheDocument() + + // Only "/deploy" should be highlighted, not "to production" + expect(highlightLayer.innerHTML).toContain( + '/deploy', + ) + expect(highlightLayer.innerHTML).not.toContain( + '/deploy to production', + ) + }) + + it("should handle commands with dashes and underscores", () => { + const { getByTestId } = render() + + const highlightLayer = getByTestId("highlight-layer") + expect(highlightLayer).toBeInTheDocument() + + // The command with dash should be highlighted + expect(highlightLayer.innerHTML).toContain( + '/test-command', + ) + }) + + it("should be case-sensitive when matching commands", () => { + const { getByTestId } = render() + + const highlightLayer = getByTestId("highlight-layer") + expect(highlightLayer).toBeInTheDocument() + + // "/Setup" (capital S) should not be highlighted since the command is "setup" (lowercase) + expect(highlightLayer.innerHTML).not.toContain( + '/Setup', + ) + expect(highlightLayer.innerHTML).toContain("/Setup") + }) + + it("should highlight multiple valid commands in the same text", () => { + const { getByTestId } = render() + + const highlightLayer = getByTestId("highlight-layer") + expect(highlightLayer).toBeInTheDocument() + + // Both valid commands should be highlighted + expect(highlightLayer.innerHTML).toContain('/setup') + expect(highlightLayer.innerHTML).toContain( + '/deploy', + ) + }) + + it("should handle mixed valid and invalid commands", () => { + const { getByTestId } = render( + , + ) + + const highlightLayer = getByTestId("highlight-layer") + expect(highlightLayer).toBeInTheDocument() + + // Valid commands should be highlighted + expect(highlightLayer.innerHTML).toContain('/setup') + expect(highlightLayer.innerHTML).toContain( + '/deploy', + ) + + // Invalid command should not be highlighted + expect(highlightLayer.innerHTML).not.toContain( + '/invalid', + ) + expect(highlightLayer.innerHTML).toContain("/invalid") + }) + + it("should work when no commands are available", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + taskHistory: [], + cwd: "/test/workspace", + commands: undefined, + }) + + const { getByTestId } = render() + + const highlightLayer = getByTestId("highlight-layer") + expect(highlightLayer).toBeInTheDocument() + + // No commands should be highlighted when commands array is undefined + expect(highlightLayer.innerHTML).not.toContain( + '/setup', + ) + expect(highlightLayer.innerHTML).toContain("/setup") + }) + }) + describe("selectApiConfig", () => { // Helper function to get the API config dropdown const getApiConfigDropdown = () => {