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:
Bohan Jiang
2026-01-23 18:14:59 +08:00
committed by GitHub
parent 2dfc6dfc80
commit ab5ce575d1
3 changed files with 156 additions and 14 deletions

View File

@@ -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)}

View File

@@ -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] }
}

View File

@@ -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'
)
})
})
})