diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 572df3e6f1..88f7452482 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -15,6 +15,7 @@ import { ContextMenuOptionType, getContextMenuOptions, insertMention, + needsSpaceBeforeMention, removeMention, shouldShowContextMenu, SearchResult, @@ -153,8 +154,7 @@ export const ChatTextArea = forwardRef( // Check if we need to add a space before the command const textBefore = currentValue.slice(0, cursorPos) - const needsSpaceBefore = textBefore.length > 0 && !textBefore.endsWith(" ") - const prefix = needsSpaceBefore ? " " : "" + const prefix = needsSpaceBeforeMention(textBefore) ? " " : "" // Insert the text at cursor position const newValue = @@ -792,9 +792,7 @@ export const ChatTextArea = forwardRef( // Check if we need to add a space before the first mention const textBefore = inputValue.slice(0, cursorPosition) - const needsSpaceBefore = - textBefore.length > 0 && !textBefore.endsWith(" ") && !textBefore.endsWith("\n") - if (needsSpaceBefore) { + if (needsSpaceBeforeMention(textBefore)) { newValue += " " totalLength += 1 } diff --git a/webview-ui/src/utils/__tests__/context-mentions.spec.ts b/webview-ui/src/utils/__tests__/context-mentions.spec.ts index 792e5be3da..56f89a5897 100644 --- a/webview-ui/src/utils/__tests__/context-mentions.spec.ts +++ b/webview-ui/src/utils/__tests__/context-mentions.spec.ts @@ -1,5 +1,6 @@ import { insertMention, + needsSpaceBeforeMention, removeMention, getContextMenuOptions, shouldShowContextMenu, @@ -8,6 +9,28 @@ import { SearchResult, } from "@src/utils/context-mentions" +describe("needsSpaceBeforeMention", () => { + it("should return false for empty text", () => { + expect(needsSpaceBeforeMention("")).toBe(false) + }) + + it("should return true for text ending with non-whitespace", () => { + expect(needsSpaceBeforeMention("Hello")).toBe(true) + expect(needsSpaceBeforeMention("Check")).toBe(true) + expect(needsSpaceBeforeMention("test123")).toBe(true) + }) + + it("should return false for text ending with space", () => { + expect(needsSpaceBeforeMention("Hello ")).toBe(false) + expect(needsSpaceBeforeMention("Check ")).toBe(false) + }) + + it("should return false for text ending with newline", () => { + expect(needsSpaceBeforeMention("Hello\n")).toBe(false) + expect(needsSpaceBeforeMention("Check\r")).toBe(false) + }) +}) + describe("insertMention", () => { it("should insert mention at cursor position when no @ symbol exists", () => { const result = insertMention("Hello world", 5, "test") @@ -116,6 +139,47 @@ describe("insertMention", () => { expect(result.newValue.includes("\\ ")).toBe(false) }) + // --- Tests for active @ detection (guarding earlier mentions) --- + describe("active @ detection", () => { + it("should not replace earlier @ when cursor is after whitespace", () => { + // Text: "Hello @file.ts now I want another" + // Cursor at position 21 (at 'w' in "want"), so beforeCursor = "Hello @file.ts now I " (ends with space) + // The @ at position 6 should NOT be replaced because there's whitespace between it and cursor + const result = insertMention("Hello @file.ts now I want another", 21, "test") + // Should insert new mention at cursor position, not replace the earlier @file.ts + // Since beforeCursor ends with space, no extra space is added + expect(result.newValue).toBe("Hello @file.ts now I @test want another") + expect(result.mentionIndex).toBe(21) + }) + + it("should replace @ when cursor is directly after it with no whitespace", () => { + // Text: "Hello @fi" + // Cursor at position 9 (right after "fi") + const result = insertMention("Hello @fi", 9, "file.ts") + // Should replace from @ since cursor is directly after partial mention + expect(result.newValue).toBe("Hello @file.ts ") + expect(result.mentionIndex).toBe(6) + }) + + it("should handle multiple @ symbols - only active one is affected", () => { + // Text: "Check @file1.ts and @fi" + // Cursor at position 23 (after "@fi") + // Should replace from the second @ (at position 20), not the first one + const result = insertMention("Check @file1.ts and @fi", 23, "file2.ts") + expect(result.newValue).toBe("Check @file1.ts and @file2.ts ") + expect(result.mentionIndex).toBe(20) + }) + + it("should insert new mention when cursor is far from any @", () => { + // Text: "Check @file.ts and then some text here" + // Cursor at position 38 (at the end after "here") + const result = insertMention("Check @file.ts and then some text here", 38, "test") + // No active @, should insert new mention at cursor + expect(result.newValue).toBe("Check @file.ts and then some text here @test ") + expect(result.mentionIndex).toBe(39) + }) + }) + // --- Tests for isSlashCommand parameter --- describe("isSlashCommand parameter", () => { it("should replace entire text when isSlashCommand is true", () => { diff --git a/webview-ui/src/utils/context-mentions.ts b/webview-ui/src/utils/context-mentions.ts index d59683dc6a..5f0a1b0802 100644 --- a/webview-ui/src/utils/context-mentions.ts +++ b/webview-ui/src/utils/context-mentions.ts @@ -25,6 +25,45 @@ function getBasename(filepath: string): string { return filepath.split("/").pop() || filepath } +/** + * Helper function to determine if a space is needed before inserting a mention. + * Returns true if the text has content and doesn't end with whitespace. + * @param text - The text to check + * @returns true if a space should be inserted before the mention + */ +export function needsSpaceBeforeMention(text: string): boolean { + if (text.length === 0) { + return false + } + const lastChar = text[text.length - 1] + return lastChar !== " " && lastChar !== "\n" && lastChar !== "\r" +} + +/** + * Finds the active @ symbol position for mention insertion. + * An @ is "active" if there's no unescaped whitespace between it and the cursor. + * @param text - Text before cursor + * @returns The index of the active @, or -1 if no active @ exists + */ +function findActiveAtIndex(text: string): number { + const lastAtIndex = text.lastIndexOf("@") + if (lastAtIndex === -1) { + return -1 + } + + // Check if there's any unescaped whitespace between @ and cursor + const textAfterAt = text.slice(lastAtIndex + 1) + // Look for unescaped whitespace (space not preceded by backslash) + const hasUnescapedWhitespace = /(? 0 && !beforeAt.endsWith(" ") && !beforeAt.endsWith("\n") + if (activeAtIndex !== -1) { + // If there's an active '@' symbol, check if we need to add a space before it + const beforeAt = text.slice(0, activeAtIndex) + const spaceBefore = needsSpaceBeforeMention(beforeAt) // If we need a space before @, add it - const beforeMention = needsSpaceBefore ? beforeAt + " " : beforeAt + const beforeMention = spaceBefore ? beforeAt + " " : beforeAt // Only replace if afterCursor is all alphanumerical // This is required to handle languages that don't use space as a word separator (chinese, japanese, korean, etc) @@ -71,15 +110,15 @@ export function insertMention( ? afterCursor.replace(/^[^\s]*/, "") : afterCursor newValue = beforeMention + "@" + processedValue + " " + afterCursorContent - mentionIndex = needsSpaceBefore ? lastAtIndex + 1 : lastAtIndex + mentionIndex = spaceBefore ? activeAtIndex + 1 : activeAtIndex } else { - // If there's no '@' symbol, insert the mention at the cursor position + // If there's no active '@' symbol, insert the mention at the cursor position // Check if we need to add a space before the mention - const needsSpaceBefore = beforeCursor.length > 0 && !beforeCursor.endsWith(" ") && !beforeCursor.endsWith("\n") - const prefix = needsSpaceBefore ? " " : "" + const spaceBefore = needsSpaceBeforeMention(beforeCursor) + const prefix = spaceBefore ? " " : "" newValue = beforeCursor + prefix + "@" + processedValue + " " + afterCursor - mentionIndex = position + (needsSpaceBefore ? 1 : 0) + mentionIndex = position + (spaceBefore ? 1 : 0) } return { newValue, mentionIndex }