fix: 修复在已有文本前输入斜杠命令无法触发自动补全,以及 Tab 补全覆盖后续文本的问题

当用户在已输入文本前插入 /command 时,光标后的文本包含空格,导致补全逻辑误判命令已有参数而跳过建议。
修复方式:只取光标前的文本(commandInput)进行命令解析和补全生成。

同时修复 Tab 补全斜杠命令时覆盖光标后文本的问题,改为在光标位置拼接补全结果。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-25 09:27:14 +08:00
parent b0a3ef90dc
commit ad09f38fd1
2 changed files with 402 additions and 19 deletions

View File

@@ -792,26 +792,30 @@ export function useTypeahead({
}
// Determine whether to display the argument hint and command suggestions.
// Only consider text up to the cursor — when the cursor is mid-input (e.g.,
// user typed "/com" before existing text), text after the cursor shouldn't
// affect command matching or argument detection.
const commandInput = value.substring(0, effectiveCursorOffset);
if (
mode === 'prompt' &&
isCommandInput(value) &&
isCommandInput(commandInput) &&
effectiveCursorOffset > 0 &&
!hasCommandWithArguments(isAtEndWithWhitespace, value)
!hasCommandWithArguments(isAtEndWithWhitespace, commandInput)
) {
let commandArgumentHint: string | undefined;
if (value.length > 1) {
if (commandInput.length > 1) {
// We have a partial or complete command without arguments
// Check if it matches a command exactly and has an argument hint
// Extract command name: everything after / until the first space (or end)
const spaceIndex = value.indexOf(' ');
const commandName = spaceIndex === -1 ? value.slice(1) : value.slice(1, spaceIndex);
const spaceIndex = commandInput.indexOf(' ');
const commandName = spaceIndex === -1 ? commandInput.slice(1) : commandInput.slice(1, spaceIndex);
// Check if there are real arguments (non-whitespace after the command)
const hasRealArguments = spaceIndex !== -1 && value.slice(spaceIndex + 1).trim().length > 0;
const hasRealArguments = spaceIndex !== -1 && commandInput.slice(spaceIndex + 1).trim().length > 0;
// Check if input is exactly "command + single space" (ready for arguments)
const hasExactlyOneTrailingSpace = spaceIndex !== -1 && value.length === spaceIndex + 1;
const hasExactlyOneTrailingSpace = spaceIndex !== -1 && commandInput.length === spaceIndex + 1;
// If input has a space after the command, don't show suggestions
// This prevents Enter from selecting a different command after Tab completion
@@ -826,8 +830,8 @@ export function useTypeahead({
commandArgumentHint = exactMatch.argumentHint;
}
// Priority 2: Progressive hint from argNames (show when trailing space)
else if (exactMatch?.type === 'prompt' && exactMatch.argNames?.length && value.endsWith(' ')) {
const argsText = value.slice(spaceIndex + 1);
else if (exactMatch?.type === 'prompt' && exactMatch.argNames?.length && commandInput.endsWith(' ')) {
const argsText = commandInput.slice(spaceIndex + 1);
const typedArgs = parseArguments(argsText);
commandArgumentHint = generateProgressiveArgumentHint(exactMatch.argNames, typedArgs);
}
@@ -846,7 +850,7 @@ export function useTypeahead({
// (set above when hasExactlyOneTrailingSpace is true)
}
const commandItems = generateCommandSuggestions(value, commands);
const commandItems = generateCommandSuggestions(commandInput, commands);
setSuggestionsState(() => ({
commandArgumentHint,
suggestions: commandItems,
@@ -867,7 +871,7 @@ export function useTypeahead({
// because there may be relevant @ symbol and file suggestions.
debouncedFetchFileSuggestions.cancel();
clearSuggestions();
} else if (isCommandInput(value) && hasCommandWithArguments(isAtEndWithWhitespace, value)) {
} else if (isCommandInput(commandInput) && hasCommandWithArguments(isAtEndWithWhitespace, commandInput)) {
// If we have a command with arguments (no trailing space), clear any stale hint
// This prevents the hint from flashing when transitioning between states
setSuggestionsState(prev => (prev.commandArgumentHint ? { ...prev, commandArgumentHint: undefined } : prev));
@@ -1030,14 +1034,20 @@ export function useTypeahead({
if (suggestionType === 'command' && index < suggestions.length) {
if (suggestion) {
applyCommandSuggestion(
suggestion,
false, // don't execute on tab
commands,
onInputChange,
setCursorOffset,
onSubmit,
);
// Splice the completed command at the cursor position, preserving
// any text after the cursor (e.g., user typed "/com" before existing text).
const metadata = suggestion.metadata;
if (
metadata &&
typeof metadata === 'object' &&
'name' in metadata &&
'type' in metadata
) {
const commandName = getCommandName(metadata as Command);
const replacement = `/${commandName} `;
onInputChange(replacement + input.slice(cursorOffset));
setCursorOffset(replacement.length);
}
clearSuggestions();
}
} else if (suggestionType === 'custom-title' && suggestions.length > 0) {

View File

@@ -0,0 +1,373 @@
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 Command
}
function makePromptCommand(
name: string,
opts?: Partial<Command>,
): Command {
return {
name,
description: opts?.description ?? `${name} skill`,
type: 'prompt',
handler: () => {},
source: 'userSettings',
...opts,
} 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)
})
})