diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx index 09088d50df..1679782707 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx @@ -585,4 +585,117 @@ describe('useCommandCompletion', () => { ); }); }); + + describe('@ completion after slash commands (issue #14420)', () => { + it('should show file suggestions when typing @path after a slash command', async () => { + setupMocks({ + atSuggestions: [{ label: 'src/file.txt', value: 'src/file.txt' }], + }); + + const text = '/mycommand @src/fi'; + const cursorOffset = text.length; + + renderCommandCompletionHook(text, cursorOffset); + + await waitFor(() => { + expect(useAtCompletion).toHaveBeenLastCalledWith( + expect.objectContaining({ + enabled: true, + pattern: 'src/fi', + }), + ); + }); + }); + + it('should show slash suggestions when cursor is on command part (no @)', async () => { + setupMocks({ + slashSuggestions: [{ label: 'mycommand', value: 'mycommand' }], + }); + + const text = '/mycom'; + const cursorOffset = text.length; + + const { result } = renderCommandCompletionHook(text, cursorOffset); + + await waitFor(() => { + expect(result.current.suggestions).toHaveLength(1); + expect(result.current.suggestions[0]?.label).toBe('mycommand'); + }); + }); + + it('should switch to @ completion when typing @ after slash command', async () => { + setupMocks({ + atSuggestions: [{ label: 'file.txt', value: 'file.txt' }], + }); + + const text = '/command @'; + const cursorOffset = text.length; + + renderCommandCompletionHook(text, cursorOffset); + + await waitFor(() => { + expect(useAtCompletion).toHaveBeenLastCalledWith( + expect.objectContaining({ + enabled: true, + pattern: '', + }), + ); + }); + }); + + it('should handle multiple @ references in a slash command', async () => { + setupMocks({ + atSuggestions: [{ label: 'src/bar.ts', value: 'src/bar.ts' }], + }); + + const text = '/diff @src/foo.ts @src/ba'; + const cursorOffset = text.length; + + renderCommandCompletionHook(text, cursorOffset); + + await waitFor(() => { + expect(useAtCompletion).toHaveBeenLastCalledWith( + expect.objectContaining({ + enabled: true, + pattern: 'src/ba', + }), + ); + }); + }); + + it('should complete file path and add trailing space', async () => { + setupMocks({ + atSuggestions: [{ label: 'src/file.txt', value: 'src/file.txt' }], + }); + + const { result } = renderCommandCompletionHook('/cmd @src/fi'); + + await waitFor(() => { + expect(result.current.suggestions.length).toBe(1); + }); + + act(() => { + result.current.handleAutocomplete(0); + }); + + expect(result.current.textBuffer.text).toBe('/cmd @src/file.txt '); + }); + + it('should stay in slash mode when slash command has trailing space but no @', async () => { + setupMocks({ + slashSuggestions: [{ label: 'help', value: 'help' }], + }); + + const text = '/help '; + renderCommandCompletionHook(text); + + await waitFor(() => { + expect(useSlashCompletion).toHaveBeenLastCalledWith( + expect.objectContaining({ + enabled: true, + }), + ); + }); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx index beabca860b..b6c4991648 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -92,16 +92,11 @@ export function useCommandCompletion( const { completionMode, query, completionStart, completionEnd } = useMemo(() => { const currentLine = buffer.lines[cursorRow] || ''; - if (cursorRow === 0 && isSlashCommand(currentLine.trim())) { - return { - completionMode: CompletionMode.SLASH, - query: currentLine, - completionStart: 0, - completionEnd: currentLine.length, - }; - } - const codePoints = toCodePoints(currentLine); + + // FIRST: Check for @ completion (scan backwards from cursor) + // This must happen before slash command check so that `/cmd @file` + // triggers file completion, not just slash command completion. for (let i = cursorCol - 1; i >= 0; i--) { const char = codePoints[i]; @@ -139,6 +134,16 @@ export function useCommandCompletion( } } + // THEN: Check for slash command (only if no @ completion is active) + if (cursorRow === 0 && isSlashCommand(currentLine.trim())) { + return { + completionMode: CompletionMode.SLASH, + query: currentLine, + completionStart: 0, + completionEnd: currentLine.length, + }; + } + // Check for prompt completion - only if enabled const trimmedText = buffer.text.trim(); const isPromptCompletionEnabled =