Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions webview-ui/src/components/chat/ChatTextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -973,6 +999,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
)}>
<div
ref={highlightLayerRef}
data-testid="highlight-layer"
className={cn(
"absolute",
"inset-0",
Expand Down
138 changes: 138 additions & 0 deletions webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down