mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -109,6 +109,7 @@ export function MessageInput({
|
||||
const [images, setImages] = useState<ImageContentItem[]>([])
|
||||
const [menuDismissed, setMenuDismissed] = useState(false)
|
||||
const [commandMenuIndex, setCommandMenuIndex] = useState(0)
|
||||
const [cursorPosition, setCursorPosition] = useState(0)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const inputContainerRef = useRef<HTMLDivElement>(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)}
|
||||
|
||||
@@ -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] }
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user