Browse Source

Better command highlighting (#6336)

Matt Rubens 5 months ago
parent
commit
00a3738d30

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

@@ -714,15 +714,41 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 
 			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) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c] || c)
 				.replace(mentionRegexGlobal, '<mark class="mention-context-textarea-highlight">$&</mark>')
-				.replace(commandRegexGlobal, '<mark class="mention-context-textarea-highlight">$&</mark>')
+
+			// 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 ` <mark class="mention-context-textarea-highlight">${commandPart}</mark>`
+					} else {
+						// Highlight the entire command (starts at beginning of line)
+						return `<mark class="mention-context-textarea-highlight">${commandPart}</mark>`
+					}
+				}
+				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<HTMLTextAreaElement, ChatTextAreaProps>(
 				)}>
 				<div
 					ref={highlightLayerRef}
+					data-testid="highlight-layer"
 					className={cn(
 						"absolute",
 						"inset-0",

+ 138 - 0
webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx

@@ -904,6 +904,144 @@ describe("ChatTextArea", () => {
 		})
 	})
 
+	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<typeof vi.fn>).mockReturnValue({
+				filePaths: [],
+				openedTabs: [],
+				taskHistory: [],
+				cwd: "/test/workspace",
+				commands: mockCommands,
+			})
+		})
+
+		it("should highlight valid slash commands", () => {
+			const { getByTestId } = render(<ChatTextArea {...defaultProps} inputValue="/setup the project" />)
+
+			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('<mark class="mention-context-textarea-highlight">/setup</mark>')
+		})
+
+		it("should not highlight invalid slash commands", () => {
+			const { getByTestId } = render(<ChatTextArea {...defaultProps} inputValue="/invalid command" />)
+
+			const highlightLayer = getByTestId("highlight-layer")
+			expect(highlightLayer).toBeInTheDocument()
+
+			// The invalid command "/invalid" should not be highlighted
+			expect(highlightLayer.innerHTML).not.toContain(
+				'<mark class="mention-context-textarea-highlight">/invalid</mark>',
+			)
+			// 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(<ChatTextArea {...defaultProps} inputValue="/deploy to production" />)
+
+			const highlightLayer = getByTestId("highlight-layer")
+			expect(highlightLayer).toBeInTheDocument()
+
+			// Only "/deploy" should be highlighted, not "to production"
+			expect(highlightLayer.innerHTML).toContain(
+				'<mark class="mention-context-textarea-highlight">/deploy</mark>',
+			)
+			expect(highlightLayer.innerHTML).not.toContain(
+				'<mark class="mention-context-textarea-highlight">/deploy to production</mark>',
+			)
+		})
+
+		it("should handle commands with dashes and underscores", () => {
+			const { getByTestId } = render(<ChatTextArea {...defaultProps} inputValue="/test-command with args" />)
+
+			const highlightLayer = getByTestId("highlight-layer")
+			expect(highlightLayer).toBeInTheDocument()
+
+			// The command with dash should be highlighted
+			expect(highlightLayer.innerHTML).toContain(
+				'<mark class="mention-context-textarea-highlight">/test-command</mark>',
+			)
+		})
+
+		it("should be case-sensitive when matching commands", () => {
+			const { getByTestId } = render(<ChatTextArea {...defaultProps} inputValue="/Setup the project" />)
+
+			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(
+				'<mark class="mention-context-textarea-highlight">/Setup</mark>',
+			)
+			expect(highlightLayer.innerHTML).toContain("/Setup")
+		})
+
+		it("should highlight multiple valid commands in the same text", () => {
+			const { getByTestId } = render(<ChatTextArea {...defaultProps} inputValue="/setup first then /deploy" />)
+
+			const highlightLayer = getByTestId("highlight-layer")
+			expect(highlightLayer).toBeInTheDocument()
+
+			// Both valid commands should be highlighted
+			expect(highlightLayer.innerHTML).toContain('<mark class="mention-context-textarea-highlight">/setup</mark>')
+			expect(highlightLayer.innerHTML).toContain(
+				'<mark class="mention-context-textarea-highlight">/deploy</mark>',
+			)
+		})
+
+		it("should handle mixed valid and invalid commands", () => {
+			const { getByTestId } = render(
+				<ChatTextArea {...defaultProps} inputValue="/setup first then /invalid then /deploy" />,
+			)
+
+			const highlightLayer = getByTestId("highlight-layer")
+			expect(highlightLayer).toBeInTheDocument()
+
+			// Valid commands should be highlighted
+			expect(highlightLayer.innerHTML).toContain('<mark class="mention-context-textarea-highlight">/setup</mark>')
+			expect(highlightLayer.innerHTML).toContain(
+				'<mark class="mention-context-textarea-highlight">/deploy</mark>',
+			)
+
+			// Invalid command should not be highlighted
+			expect(highlightLayer.innerHTML).not.toContain(
+				'<mark class="mention-context-textarea-highlight">/invalid</mark>',
+			)
+			expect(highlightLayer.innerHTML).toContain("/invalid")
+		})
+
+		it("should work when no commands are available", () => {
+			;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
+				filePaths: [],
+				openedTabs: [],
+				taskHistory: [],
+				cwd: "/test/workspace",
+				commands: undefined,
+			})
+
+			const { getByTestId } = render(<ChatTextArea {...defaultProps} inputValue="/setup the project" />)
+
+			const highlightLayer = getByTestId("highlight-layer")
+			expect(highlightLayer).toBeInTheDocument()
+
+			// No commands should be highlighted when commands array is undefined
+			expect(highlightLayer.innerHTML).not.toContain(
+				'<mark class="mention-context-textarea-highlight">/setup</mark>',
+			)
+			expect(highlightLayer.innerHTML).toContain("/setup")
+		})
+	})
+
 	describe("selectApiConfig", () => {
 		// Helper function to get the API config dropdown
 		const getApiConfigDropdown = () => {