From 343ed6be42f696a09f775bf3169b65215162aeb1 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sun, 29 Mar 2026 00:30:10 +0000 Subject: [PATCH] fix: enable slash command chaining on any line in multiline input Previously, slash commands only worked on the first line of the textarea. This was because shouldShowContextMenu(), handleChange(), and insertMention() all checked against the full text value rather than the current line where the cursor is positioned. Changes: - shouldShowContextMenu: extract current line from cursor position and check if it starts with "/" instead of checking entire text - ChatTextArea handleChange: same current-line extraction for slash command detection and query building - insertMention: when isSlashCommand is true, replace only the slash token on the current line instead of the entire text content Closes #12028 --- .../src/components/chat/ChatTextArea.tsx | 10 ++-- .../utils/__tests__/context-mentions.spec.ts | 49 +++++++++++++++++-- webview-ui/src/utils/context-mentions.ts | 17 +++++-- 3 files changed, 65 insertions(+), 11 deletions(-) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index e72c1726f35..1b436bca7d7 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -598,9 +598,13 @@ export const ChatTextArea = forwardRef( setShowContextMenu(showMenu) if (showMenu) { - if (newValue.startsWith("/") && !newValue.includes(" ")) { - // Handle slash command - request fresh commands - const query = newValue + // Extract current line based on cursor position + const lineStart = newValue.lastIndexOf("\n", newCursorPosition - 1) + 1 + const currentLineBefore = newValue.slice(lineStart, newCursorPosition) + + if (currentLineBefore.startsWith("/") && !currentLineBefore.includes(" ")) { + // Handle slash command on current line - request fresh commands + const query = currentLineBefore setSearchQuery(query) // Set to first selectable item (skip section headers) setSelectedMenuIndex(1) // Section header is at 0, first command is at 1 diff --git a/webview-ui/src/utils/__tests__/context-mentions.spec.ts b/webview-ui/src/utils/__tests__/context-mentions.spec.ts index 48b03907c34..efc72aa7771 100644 --- a/webview-ui/src/utils/__tests__/context-mentions.spec.ts +++ b/webview-ui/src/utils/__tests__/context-mentions.spec.ts @@ -52,7 +52,7 @@ describe("insertMention", () => { it("should handle slash command replacement", () => { const result = insertMention("/mode some", 5, "code", true) // Simulating mode selection - expect(result.newValue).toBe("code") // Should replace the whole text + expect(result.newValue).toBe("code some") // Should replace slash token on current line, preserve rest expect(result.mentionIndex).toBe(0) }) @@ -106,18 +106,34 @@ describe("insertMention", () => { // --- Tests for isSlashCommand parameter --- describe("isSlashCommand parameter", () => { - it("should replace entire text when isSlashCommand is true", () => { + it("should replace slash token on current line when isSlashCommand is true", () => { const result = insertMention("/cod", 4, "code", true) expect(result.newValue).toBe("code") expect(result.mentionIndex).toBe(0) }) - it("should replace entire text even when @ mentions exist and isSlashCommand is true", () => { + it("should replace slash token but preserve text after cursor when isSlashCommand is true", () => { const result = insertMention("/code @some/file.ts", 5, "debug", true) - expect(result.newValue).toBe("debug") + expect(result.newValue).toBe("debug @some/file.ts") expect(result.mentionIndex).toBe(0) }) + it("should replace only current line slash token in multiline text", () => { + const text = "/code\n/deb" + const position = 10 // cursor at end of "/deb" + const result = insertMention(text, position, "debug", true) + expect(result.newValue).toBe("/code\ndebug") + expect(result.mentionIndex).toBe(6) // start of second line + }) + + it("should preserve other lines when inserting slash command on second line", () => { + const text = "/code\nsome text here\n/arc" + const position = 25 // cursor at end of "/arc" + const result = insertMention(text, position, "architect", true) + expect(result.newValue).toBe("/code\nsome text here\narchitect") + expect(result.mentionIndex).toBe(21) // start of third line + }) + it("should insert @ mention correctly after slash command when isSlashCommand is false", () => { const text = "/code @" const position = 8 // cursor after @ @@ -585,4 +601,29 @@ describe("shouldShowContextMenu", () => { // This case means the regex wouldn't match anyway, but confirms context menu logic expect(shouldShowContextMenu("@/path/with space", 13)).toBe(false) // Cursor after unescaped space }) + + // --- Multiline slash command tests --- + it("should return true for slash command on second line", () => { + const text = "/code\n/deb" + const position = 10 // cursor at end of "/deb" + expect(shouldShowContextMenu(text, position)).toBe(true) + }) + + it("should return true for slash command on third line", () => { + const text = "/code\nsome text\n/arc" + const position = 19 // cursor at end of "/arc" + expect(shouldShowContextMenu(text, position)).toBe(true) + }) + + it("should return false for non-slash text on second line without @", () => { + const text = "/code\nhello" + const position = 11 // cursor at end of "hello" + expect(shouldShowContextMenu(text, position)).toBe(false) + }) + + it("should return false for slash command with space on second line", () => { + const text = "/code\n/debug something" + const position = 13 // cursor after space in "/debug " + expect(shouldShowContextMenu(text, position)).toBe(false) + }) }) diff --git a/webview-ui/src/utils/context-mentions.ts b/webview-ui/src/utils/context-mentions.ts index 22dba8864fc..40124212e91 100644 --- a/webview-ui/src/utils/context-mentions.ts +++ b/webview-ui/src/utils/context-mentions.ts @@ -32,9 +32,16 @@ export function insertMention( ): { newValue: string; mentionIndex: number } { // Handle slash command selection (only when explicitly selecting a slash command) if (isSlashCommand) { + const beforeCursor = text.slice(0, position) + const afterCursor = text.slice(position) + // Find the start of the current line + const currentLineStart = beforeCursor.lastIndexOf("\n") + 1 + const beforeLine = text.slice(0, currentLineStart) + // Replace slash command token on the current line (from line start to cursor) + const newValue = beforeLine + value + afterCursor return { - newValue: value, - mentionIndex: 0, + newValue, + mentionIndex: currentLineStart, } } @@ -367,8 +374,10 @@ export function getContextMenuOptions( export function shouldShowContextMenu(text: string, position: number): boolean { const beforeCursor = text.slice(0, position) - // Check if we're in a slash command context (at the beginning and no space yet) - if (text.startsWith("/") && !text.includes(" ") && position <= text.length) { + // Check if we're in a slash command context on the current line + const currentLineStart = beforeCursor.lastIndexOf("\n") + 1 + const currentLineBefore = beforeCursor.slice(currentLineStart) + if (currentLineBefore.startsWith("/") && !currentLineBefore.includes(" ")) { return true }