mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-19 14:55:50 +00:00
374 lines
13 KiB
TypeScript
374 lines
13 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
|
import { type Command, getCommandName } from '../../../commands.js'
|
|
import type { SuggestionItem } from '../../../components/PromptInput/PromptInputFooterSuggestions.js'
|
|
import {
|
|
applyCommandSuggestion,
|
|
findMidInputSlashCommand,
|
|
formatCommand,
|
|
generateCommandSuggestions,
|
|
getBestCommandMatch,
|
|
hasCommandArgs,
|
|
isCommandInput,
|
|
} from '../commandSuggestions.js'
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
function makeCommand(name: string, opts?: Partial<Command>): Command {
|
|
return {
|
|
name,
|
|
description: opts?.description ?? `${name} command`,
|
|
type: 'local',
|
|
handler: () => {},
|
|
...opts,
|
|
} as unknown as Command
|
|
}
|
|
|
|
function makePromptCommand(name: string, opts?: Partial<Command>): Command {
|
|
return {
|
|
name,
|
|
description: opts?.description ?? `${name} skill`,
|
|
type: 'prompt',
|
|
handler: () => {},
|
|
source: 'userSettings',
|
|
...opts,
|
|
} as unknown as Command
|
|
}
|
|
|
|
// ─── isCommandInput ───────────────────────────────────────────────────
|
|
|
|
describe('isCommandInput', () => {
|
|
test('returns true for slash-prefixed input', () => {
|
|
expect(isCommandInput('/commit')).toBe(true)
|
|
})
|
|
|
|
test('returns false for non-slash input', () => {
|
|
expect(isCommandInput('commit')).toBe(false)
|
|
})
|
|
|
|
test('returns true for just a slash', () => {
|
|
expect(isCommandInput('/')).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ─── hasCommandArgs ───────────────────────────────────────────────────
|
|
|
|
describe('hasCommandArgs', () => {
|
|
test('returns false when no space in input', () => {
|
|
expect(hasCommandArgs('/commit')).toBe(false)
|
|
})
|
|
|
|
test('returns false when only trailing space', () => {
|
|
expect(hasCommandArgs('/commit ')).toBe(false)
|
|
})
|
|
|
|
test('returns true when there are real arguments', () => {
|
|
expect(hasCommandArgs('/commit msg')).toBe(true)
|
|
})
|
|
|
|
test('returns false for non-command input', () => {
|
|
expect(hasCommandArgs('commit msg')).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ─── formatCommand ────────────────────────────────────────────────────
|
|
|
|
describe('formatCommand', () => {
|
|
test('formats command with leading slash and trailing space', () => {
|
|
expect(formatCommand('commit')).toBe('/commit ')
|
|
})
|
|
})
|
|
|
|
// ─── findMidInputSlashCommand ─────────────────────────────────────────
|
|
|
|
describe('findMidInputSlashCommand', () => {
|
|
test('returns null when input starts with slash', () => {
|
|
expect(findMidInputSlashCommand('/commit some args', 7)).toBeNull()
|
|
})
|
|
|
|
test('finds slash command after whitespace', () => {
|
|
const result = findMidInputSlashCommand('help me /com', 12)
|
|
expect(result).not.toBeNull()
|
|
expect(result!.token).toBe('/com')
|
|
expect(result!.startPos).toBe(8)
|
|
expect(result!.partialCommand).toBe('com')
|
|
})
|
|
|
|
test('returns null when no whitespace before slash', () => {
|
|
expect(findMidInputSlashCommand('help/com', 8)).toBeNull()
|
|
})
|
|
|
|
test('returns null when cursor is past the command with trailing text', () => {
|
|
expect(findMidInputSlashCommand('help /commit msg', 15)).toBeNull()
|
|
})
|
|
})
|
|
|
|
// ─── generateCommandSuggestions ────────────────────────────────────────
|
|
|
|
describe('generateCommandSuggestions', () => {
|
|
const commands: Command[] = [
|
|
makeCommand('commit'),
|
|
makeCommand('compact'),
|
|
makePromptCommand('sdd-global-read'),
|
|
makePromptCommand('sdd-archive'),
|
|
]
|
|
|
|
test('returns empty for non-slash input', () => {
|
|
expect(generateCommandSuggestions('commit', commands)).toHaveLength(0)
|
|
})
|
|
|
|
test('returns all commands for bare slash', () => {
|
|
const results = generateCommandSuggestions('/', commands)
|
|
expect(results.length).toBeGreaterThan(0)
|
|
})
|
|
|
|
test('filters by partial command name', () => {
|
|
const results = generateCommandSuggestions('/com', commands)
|
|
const names = results.map(r => r.displayText)
|
|
expect(names.some(n => n.includes('commit'))).toBe(true)
|
|
expect(names.some(n => n.includes('compact'))).toBe(true)
|
|
})
|
|
|
|
test('returns empty when command has arguments', () => {
|
|
expect(generateCommandSuggestions('/commit msg', commands)).toHaveLength(0)
|
|
})
|
|
|
|
// ★ Core regression test: cursor-aware commandInput should not be
|
|
// affected by text after the cursor. Previously, passing the full input
|
|
// "/sdd-existing text" would fail because hasCommandArgs detected the
|
|
// space from the post-cursor text. The fix slices value to cursorOffset
|
|
// before calling generateCommandSuggestions.
|
|
test('suggests commands when called with cursor-sliced input (post-cursor text ignored)', () => {
|
|
// Simulates: input="/sdd-existing text", cursor at position 5
|
|
// The caller now passes input.substring(0, cursorOffset) = "/sdd-"
|
|
const cursorOffset = 5
|
|
const fullInput = '/sdd-existing text'
|
|
const commandInput = fullInput.substring(0, cursorOffset)
|
|
|
|
expect(hasCommandArgs(commandInput)).toBe(false)
|
|
const results = generateCommandSuggestions(commandInput, commands)
|
|
const names = results.map(r => r.displayText)
|
|
expect(names.some(n => n.includes('sdd-global-read'))).toBe(true)
|
|
expect(names.some(n => n.includes('sdd-archive'))).toBe(true)
|
|
})
|
|
|
|
test('shows suggestions for bare slash even with text after cursor', () => {
|
|
// input="/hello world", cursor at position 1 → commandInput="/"
|
|
const commandInput = '/'.substring(0, 1)
|
|
const results = generateCommandSuggestions(commandInput, commands)
|
|
expect(results.length).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
// ─── getBestCommandMatch ──────────────────────────────────────────────
|
|
|
|
describe('getBestCommandMatch', () => {
|
|
const commands: Command[] = [
|
|
makeCommand('commit'),
|
|
makeCommand('compact'),
|
|
makePromptCommand('sdd-global-read'),
|
|
]
|
|
|
|
test('returns matching suffix for prefix match', () => {
|
|
const result = getBestCommandMatch('com', commands)
|
|
expect(result).not.toBeNull()
|
|
expect(result!.suffix.length).toBeGreaterThan(0)
|
|
})
|
|
|
|
test('returns null for no match', () => {
|
|
expect(getBestCommandMatch('xyz', commands)).toBeNull()
|
|
})
|
|
|
|
test('returns null for empty query', () => {
|
|
expect(getBestCommandMatch('', commands)).toBeNull()
|
|
})
|
|
|
|
// ★ Verifies that slicing to cursor position lets the fuzzy matching work
|
|
test('finds match when partial includes dash separator', () => {
|
|
const result = getBestCommandMatch('sdd', commands)
|
|
expect(result).not.toBeNull()
|
|
expect(result!.fullCommand).toBe('sdd-global-read')
|
|
})
|
|
})
|
|
|
|
// ─── applyCommandSuggestion (Enter behavior) ──────────────────────────
|
|
|
|
describe('applyCommandSuggestion', () => {
|
|
const commands: Command[] = [
|
|
makeCommand('commit', { argumentHint: '[message]' }),
|
|
]
|
|
|
|
test('replaces entire input with formatted command', () => {
|
|
let newInput = ''
|
|
let newCursor = -1
|
|
const suggestion: SuggestionItem = {
|
|
id: 'commit:local',
|
|
displayText: '/commit',
|
|
description: 'commit command',
|
|
metadata: commands[0],
|
|
}
|
|
|
|
applyCommandSuggestion(
|
|
suggestion,
|
|
false,
|
|
commands,
|
|
v => {
|
|
newInput = v
|
|
},
|
|
c => {
|
|
newCursor = c
|
|
},
|
|
() => {},
|
|
)
|
|
|
|
expect(newInput).toBe('/commit ')
|
|
expect(newCursor).toBe('/commit '.length)
|
|
})
|
|
|
|
test('executes command when shouldExecute is true', () => {
|
|
let submitted = ''
|
|
const suggestion: SuggestionItem = {
|
|
id: 'commit:local',
|
|
displayText: '/commit',
|
|
description: 'commit command',
|
|
metadata: commands[0],
|
|
}
|
|
|
|
applyCommandSuggestion(
|
|
suggestion,
|
|
true,
|
|
commands,
|
|
() => {},
|
|
() => {},
|
|
v => {
|
|
submitted = v
|
|
},
|
|
)
|
|
|
|
expect(submitted).toBe('/commit ')
|
|
})
|
|
})
|
|
|
|
// ─── Tab completion splice behavior ───────────────────────────────────
|
|
// Tests the splice-at-cursor logic that was added to handle Tab completion
|
|
// preserving text after the cursor. This mirrors the inline logic in
|
|
// handleTab (useTypeahead.tsx) where applyCommandSuggestion is bypassed
|
|
// in favor of direct splice.
|
|
|
|
describe('Tab completion splice behavior', () => {
|
|
// Simulates the handleTab splice logic:
|
|
// const replacement = `/${commandName} `
|
|
// onInputChange(replacement + input.slice(cursorOffset))
|
|
// setCursorOffset(replacement.length)
|
|
|
|
function simulateTabCompletion(
|
|
commandName: string,
|
|
input: string,
|
|
cursorOffset: number,
|
|
): { newInput: string; newCursorOffset: number } {
|
|
const replacement = `/${commandName} `
|
|
return {
|
|
newInput: replacement + input.slice(cursorOffset),
|
|
newCursorOffset: replacement.length,
|
|
}
|
|
}
|
|
|
|
test('preserves text after cursor when completing mid-input command', () => {
|
|
// User has "existing text here", types "/sdd-" at beginning, then
|
|
// presses Tab to accept "sdd-global-read" suggestion
|
|
const input = '/sdd-existing text here'
|
|
const cursorOffset = 5 // after "/sdd-"
|
|
|
|
const result = simulateTabCompletion('sdd-global-read', input, cursorOffset)
|
|
|
|
expect(result.newInput).toBe('/sdd-global-read existing text here')
|
|
expect(result.newCursorOffset).toBe('/sdd-global-read '.length)
|
|
})
|
|
|
|
test('works normally when cursor is at end of input', () => {
|
|
// Standard case: cursor at end, no text after cursor
|
|
const input = '/com'
|
|
const cursorOffset = 4
|
|
|
|
const result = simulateTabCompletion('commit', input, cursorOffset)
|
|
|
|
expect(result.newInput).toBe('/commit ')
|
|
expect(result.newCursorOffset).toBe('/commit '.length)
|
|
})
|
|
|
|
test('preserves single word after cursor', () => {
|
|
const input = '/comworld'
|
|
const cursorOffset = 4
|
|
|
|
const result = simulateTabCompletion('commit', input, cursorOffset)
|
|
|
|
expect(result.newInput).toBe('/commit world')
|
|
expect(result.newCursorOffset).toBe('/commit '.length)
|
|
})
|
|
|
|
test('preserves multiline text after cursor', () => {
|
|
const input = '/comline1\nline2'
|
|
const cursorOffset = 4
|
|
|
|
const result = simulateTabCompletion('commit', input, cursorOffset)
|
|
|
|
expect(result.newInput).toBe('/commit line1\nline2')
|
|
expect(result.newCursorOffset).toBe('/commit '.length)
|
|
})
|
|
|
|
test('handles empty text after cursor identically to end-of-input', () => {
|
|
const input = '/commit'
|
|
const endResult = simulateTabCompletion('commit', input, 7)
|
|
|
|
expect(endResult.newInput).toBe('/commit ')
|
|
})
|
|
})
|
|
|
|
// ─── hasCommandWithArguments with cursor-sliced input ─────────────────
|
|
// Tests the helper function used in updateSuggestions to determine if
|
|
// command has arguments. After the fix, only the text before cursor is
|
|
// passed, so post-cursor text doesn't affect the check.
|
|
|
|
describe('hasCommandWithArguments (cursor-aware usage)', () => {
|
|
function hasCommandWithArguments(
|
|
isAtEndWithWhitespace: boolean,
|
|
value: string,
|
|
): boolean {
|
|
return !isAtEndWithWhitespace && value.includes(' ') && !value.endsWith(' ')
|
|
}
|
|
|
|
test('returns false when cursor-sliced input has no space', () => {
|
|
// input="/sdd-existing text", cursorOffset=5 → commandInput="/sdd-"
|
|
const commandInput = '/sdd-'
|
|
expect(hasCommandWithArguments(false, commandInput)).toBe(false)
|
|
})
|
|
|
|
test('returns true when cursor-sliced input has real arguments', () => {
|
|
// input="/commit msg rest", cursorOffset=11 → commandInput="/commit msg"
|
|
const commandInput = '/commit msg'
|
|
expect(hasCommandWithArguments(false, commandInput)).toBe(true)
|
|
})
|
|
|
|
test('returns false for trailing space (ready for arguments)', () => {
|
|
const commandInput = '/commit '
|
|
expect(hasCommandWithArguments(false, commandInput)).toBe(false)
|
|
})
|
|
|
|
test('returns false when cursor is at end with trailing space', () => {
|
|
// isAtEndWithWhitespace=true → always false
|
|
expect(hasCommandWithArguments(true, '/commit ')).toBe(false)
|
|
})
|
|
|
|
test('does not match space from post-cursor text', () => {
|
|
// Before fix: full input "/sdd-existing text" → hasCommandWithArguments = true
|
|
// After fix: sliced input "/sdd-" → hasCommandWithArguments = false
|
|
const fullInput = '/sdd-existing text'
|
|
const cursorOffset = 5
|
|
const commandInput = fullInput.substring(0, cursorOffset)
|
|
|
|
expect(commandInput).toBe('/sdd-')
|
|
expect(hasCommandWithArguments(false, commandInput)).toBe(false)
|
|
// Verify the full input WOULD have been true (proving the bug existed)
|
|
expect(hasCommandWithArguments(false, fullInput)).toBe(true)
|
|
})
|
|
})
|