From ab5ce575d13e203cd635ed957df333d83fb3ce56 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Fri, 23 Jan 2026 18:14:59 +0800 Subject: [PATCH] fix: slash command not triggering in multiline input (#112) The slash command menu only detected `/` at the start of the entire input text. Now it detects `/` at the start of the current line where the cursor is positioned, enabling slash commands to work in multiline input. Changes: - Track cursor position in MessageInput to extract current line - Parse slash command from current line at cursor instead of full input - Update handleCommandSelect to replace only the current line's command - Add onSelect handler to track cursor movement - Update regex in parseSlashCommand to support multiline arguments - Add unit tests for multiline slash command scenarios Closes #109 Co-authored-by: Claude Opus 4.5 --- src/renderer/src/components/MessageInput.tsx | 68 ++++++++++--- src/renderer/src/utils/slashCommand.ts | 4 +- .../components/SlashCommandMenu.test.ts | 98 +++++++++++++++++++ 3 files changed, 156 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/components/MessageInput.tsx b/src/renderer/src/components/MessageInput.tsx index be4fe8dea..972d22c49 100644 --- a/src/renderer/src/components/MessageInput.tsx +++ b/src/renderer/src/components/MessageInput.tsx @@ -109,6 +109,7 @@ export function MessageInput({ const [images, setImages] = useState([]) const [menuDismissed, setMenuDismissed] = useState(false) const [commandMenuIndex, setCommandMenuIndex] = useState(0) + const [cursorPosition, setCursorPosition] = useState(0) const textareaRef = useRef(null) const fileInputRef = useRef(null) const inputContainerRef = useRef(null) @@ -118,16 +119,23 @@ export function MessageInput({ // Get available commands from store const availableCommands = useCommandStore((state) => state.availableCommands) - // Parse current slash command state - const parsedCommand = useMemo(() => parseSlashCommand(value), [value]) + // Extract current line at cursor position for slash command detection + const currentLineAtCursor = useMemo(() => { + const textBeforeCursor = value.slice(0, cursorPosition) + const lastNewline = textBeforeCursor.lastIndexOf('\n') + return textBeforeCursor.slice(lastNewline + 1) + }, [value, cursorPosition]) + + // Parse current slash command state based on the current line at cursor + const parsedCommand = useMemo(() => parseSlashCommand(currentLineAtCursor), [currentLineAtCursor]) const commandFilter = parsedCommand?.command || '' const isInCommandMode = parsedCommand !== null && parsedCommand.argument === undefined const showCommandMenu = isInCommandMode && availableCommands.length > 0 && !menuDismissed const commandError = useMemo(() => { if (!parsedCommand || !parsedCommand.command) return null if (availableCommands.length === 0 || isInCommandMode) return null - return validateCommand(value, availableCommands) - }, [parsedCommand, availableCommands, isInCommandMode, value]) + return validateCommand(currentLineAtCursor, availableCommands) + }, [parsedCommand, availableCommands, isInCommandMode, currentLineAtCursor]) // Auto-resize textarea useEffect(() => { @@ -278,14 +286,38 @@ export function MessageInput({ }, []) // Handle slash command selection from menu - const handleCommandSelect = useCallback((command: AvailableCommand) => { - // Replace the current "/" text with the selected command - const hasInput = command.input - setValue(`/${command.name}${hasInput ? ' ' : ''}`) - setMenuDismissed(true) - // Focus back on textarea - textareaRef.current?.focus() - }, []) + const handleCommandSelect = useCallback( + (command: AvailableCommand) => { + // Replace the current line's slash command text with the selected command + const hasInput = command.input + const commandText = `/${command.name}${hasInput ? ' ' : ''}` + + const textBeforeCursor = value.slice(0, cursorPosition) + const lastNewline = textBeforeCursor.lastIndexOf('\n') + const lineStart = lastNewline + 1 + const textAfterCursor = value.slice(cursorPosition) + + // Build new value: text before current line + command + text after cursor + const newValue = value.slice(0, lineStart) + commandText + textAfterCursor + const newCursor = lineStart + commandText.length + + setValue(newValue) + setCursorPosition(newCursor) + setMenuDismissed(true) + + // Focus back on textarea and set cursor position + const textarea = textareaRef.current + if (textarea) { + textarea.focus() + // Use requestAnimationFrame to ensure React has updated the value + requestAnimationFrame(() => { + textarea.selectionStart = newCursor + textarea.selectionEnd = newCursor + }) + } + }, + [value, cursorPosition] + ) // Focus textarea after selector completion (model/agent/mode selection) const handleSelectorComplete = useCallback(() => { @@ -394,17 +426,27 @@ export function MessageInput({ value={value} onChange={(e) => { const nextValue = e.target.value + const nextCursor = e.target.selectionStart ?? 0 if (menuDismissed) { setMenuDismissed(false) } - const nextParsed = parseSlashCommand(nextValue) + // Extract current line at cursor for command detection + const textBeforeCursor = nextValue.slice(0, nextCursor) + const lastNewline = textBeforeCursor.lastIndexOf('\n') + const currentLine = textBeforeCursor.slice(lastNewline + 1) + const nextParsed = parseSlashCommand(currentLine) const nextIsCommandMode = nextParsed !== null && nextParsed.argument === undefined if (nextIsCommandMode && availableCommands.length > 0) { setCommandMenuIndex(0) } + setCursorPosition(nextCursor) setValue(nextValue) }} onKeyDown={handleKeyDown} + onSelect={(e) => { + const pos = (e.target as HTMLTextAreaElement).selectionStart ?? 0 + setCursorPosition(pos) + }} onPaste={handlePaste} onCompositionStart={() => setIsComposing(true)} onCompositionEnd={() => setIsComposing(false)} diff --git a/src/renderer/src/utils/slashCommand.ts b/src/renderer/src/utils/slashCommand.ts index c690f7c87..12aa52b49 100644 --- a/src/renderer/src/utils/slashCommand.ts +++ b/src/renderer/src/utils/slashCommand.ts @@ -6,10 +6,12 @@ import type { AvailableCommand } from '../../../shared/types' /** * Parse slash command from input text * Returns the command name and optional argument if input starts with "/" + * Supports multiline input: the first line contains the command, and any + * remaining content (after the command name and optional space) is the argument. */ export function parseSlashCommand(text: string): { command?: string; argument?: string } | null { if (!text.startsWith('/')) return null - const match = text.match(/^\/(\S*)(?:\s+(.*))?$/) + const match = text.match(/^\/(\S*)(?:\s+([\s\S]*))?$/) if (!match) return null return { command: match[1], argument: match[2] } } diff --git a/tests/unit/renderer/components/SlashCommandMenu.test.ts b/tests/unit/renderer/components/SlashCommandMenu.test.ts index 13d5b69d4..5cdbdf2b6 100644 --- a/tests/unit/renderer/components/SlashCommandMenu.test.ts +++ b/tests/unit/renderer/components/SlashCommandMenu.test.ts @@ -5,6 +5,16 @@ import { describe, it, expect } from 'vitest' import { parseSlashCommand, validateCommand } from '../../../../src/renderer/src/utils/slashCommand' import type { AvailableCommand } from '../../../../src/shared/types' +/** + * Helper to extract the current line at a given cursor position, + * mirroring the logic in MessageInput.tsx + */ +function getCurrentLineAtCursor(text: string, cursorPosition: number): string { + const textBeforeCursor = text.slice(0, cursorPosition) + const lastNewline = textBeforeCursor.lastIndexOf('\n') + return textBeforeCursor.slice(lastNewline + 1) +} + describe('SlashCommandMenu', () => { describe('parseSlashCommand', () => { it('should return null for non-command input', () => { @@ -33,6 +43,86 @@ describe('SlashCommandMenu', () => { it('should handle command with empty argument after space', () => { expect(parseSlashCommand('/help ')).toEqual({ command: 'help', argument: '' }) }) + + it('should parse command with multiline argument', () => { + expect(parseSlashCommand('/help line1\nline2')).toEqual({ + command: 'help', + argument: 'line1\nline2' + }) + expect(parseSlashCommand('/search hello\nworld\nfoo')).toEqual({ + command: 'search', + argument: 'hello\nworld\nfoo' + }) + }) + + it('should parse command name when followed by newline without space', () => { + // "/help\n" — no space before newline, so command is "help\n..." which is non-whitespace + // Actually \n is whitespace, so \S* stops at \n. Let's verify behavior: + // The regex ^\/(\S*)(?:\s+([\s\S]*))?$ with input "/help\nmore" + // \S* matches "help", then \s+ matches \n, then [\s\S]* matches "more" + expect(parseSlashCommand('/help\nmore')).toEqual({ + command: 'help', + argument: 'more' + }) + }) + + it('should parse standalone command with trailing newline', () => { + expect(parseSlashCommand('/help\n')).toEqual({ + command: 'help', + argument: '' + }) + }) + }) + + describe('getCurrentLineAtCursor (multiline slash command detection)', () => { + it('should return the full text when there is no newline', () => { + expect(getCurrentLineAtCursor('/help', 5)).toBe('/help') + expect(getCurrentLineAtCursor('/', 1)).toBe('/') + }) + + it('should return the current line when cursor is on a new line', () => { + // User typed "hello\n/" - cursor at end (position 7) + expect(getCurrentLineAtCursor('hello\n/', 7)).toBe('/') + // User typed "hello\n/he" - cursor at end (position 9) + expect(getCurrentLineAtCursor('hello\n/he', 9)).toBe('/he') + }) + + it('should return the current line for multi-line input', () => { + const text = 'line1\nline2\n/search' + // Cursor at end (position 19) + expect(getCurrentLineAtCursor(text, 19)).toBe('/search') + }) + + it('should detect slash command on any line with cursor', () => { + const text = 'some text\n/help\nmore text' + // Cursor after "/help" (position 15) + expect(getCurrentLineAtCursor(text, 15)).toBe('/help') + }) + + it('should not detect slash command when cursor is on a non-command line', () => { + const text = '/help\nnon-command line' + // Cursor at end of second line (position 21) + const currentLine = getCurrentLineAtCursor(text, 21) + expect(parseSlashCommand(currentLine)).toBeNull() + }) + + it('should detect slash command when cursor is in the middle of command text', () => { + // User typed "/hel" and cursor is at position 4 + const text = 'hello\n/hel' + expect(getCurrentLineAtCursor(text, 10)).toBe('/hel') + expect(parseSlashCommand(getCurrentLineAtCursor(text, 10))).toEqual({ + command: 'hel', + argument: undefined + }) + }) + + it('should handle empty line after newline', () => { + expect(getCurrentLineAtCursor('hello\n', 6)).toBe('') + }) + + it('should handle cursor at position 0', () => { + expect(getCurrentLineAtCursor('/help', 0)).toBe('') + }) }) describe('validateCommand', () => { @@ -67,5 +157,13 @@ describe('SlashCommandMenu', () => { it('should return null when no commands available', () => { expect(validateCommand('/help', [])).toBeNull() }) + + it('should validate command with multiline argument', () => { + expect(validateCommand('/help line1\nline2', mockCommands)).toBeNull() + expect(validateCommand('/search query\nmore', mockCommands)).toBeNull() + expect(validateCommand('/invalid line1\nline2', mockCommands)).toBe( + '/invalid is not a valid command' + ) + }) }) })