mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-16 22:59:04 +02:00
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>
170 lines
6.3 KiB
TypeScript
170 lines
6.3 KiB
TypeScript
/**
|
|
* Tests for slash command utility functions
|
|
*/
|
|
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', () => {
|
|
expect(parseSlashCommand('hello')).toBeNull()
|
|
expect(parseSlashCommand(' /hello')).toBeNull()
|
|
expect(parseSlashCommand('')).toBeNull()
|
|
})
|
|
|
|
it('should parse command without argument', () => {
|
|
expect(parseSlashCommand('/')).toEqual({ command: '', argument: undefined })
|
|
expect(parseSlashCommand('/help')).toEqual({ command: 'help', argument: undefined })
|
|
expect(parseSlashCommand('/create_plan')).toEqual({
|
|
command: 'create_plan',
|
|
argument: undefined
|
|
})
|
|
})
|
|
|
|
it('should parse command with argument', () => {
|
|
expect(parseSlashCommand('/help me')).toEqual({ command: 'help', argument: 'me' })
|
|
expect(parseSlashCommand('/search hello world')).toEqual({
|
|
command: 'search',
|
|
argument: 'hello world'
|
|
})
|
|
})
|
|
|
|
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', () => {
|
|
const mockCommands: AvailableCommand[] = [
|
|
{ name: 'help', description: 'Show help' },
|
|
{ name: 'search', description: 'Search the codebase' },
|
|
{ name: 'create_plan', description: 'Create a plan' }
|
|
]
|
|
|
|
it('should return null for non-command input', () => {
|
|
expect(validateCommand('hello', mockCommands)).toBeNull()
|
|
expect(validateCommand('', mockCommands)).toBeNull()
|
|
})
|
|
|
|
it('should return null for valid command', () => {
|
|
expect(validateCommand('/help', mockCommands)).toBeNull()
|
|
expect(validateCommand('/help me', mockCommands)).toBeNull()
|
|
expect(validateCommand('/search query', mockCommands)).toBeNull()
|
|
})
|
|
|
|
it('should return null for incomplete command (empty name)', () => {
|
|
expect(validateCommand('/', mockCommands)).toBeNull()
|
|
})
|
|
|
|
it('should return error for invalid command', () => {
|
|
expect(validateCommand('/invalid', mockCommands)).toBe('/invalid is not a valid command')
|
|
expect(validateCommand('/unknown command', mockCommands)).toBe(
|
|
'/unknown is not a valid command'
|
|
)
|
|
})
|
|
|
|
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'
|
|
)
|
|
})
|
|
})
|
|
})
|