diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index c4bfa74fe62..680a65368ed 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -522,6 +522,8 @@ Use `/auth` inside a Qwen Code session to configure authentication. Use `/doctor Note: Spaces in paths need to be escaped with backslash (e.g., `@My\ Documents/file.txt`) +In the interactive prompt, `@` completion supports Unicode filenames, including emoji. Emoji elsewhere in the prompt do not change the file path used for completion. + ## 3. Exclamation Commands (`!`) - Shell Command Execution Exclamation commands allow you to execute system commands directly within Qwen Code. diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.test.ts b/packages/cli/src/ui/hooks/useCommandCompletion.test.ts index d804f61fd31..0ece7373279 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useCommandCompletion.test.ts @@ -487,6 +487,61 @@ describe('useCommandCompletion', () => { }); describe('Completion mode detection', () => { + it.each([ + ['hello @README.md', 'README.md'], + ['中文 @文件.ts', '文件.ts'], + ['😀 @README.md', 'README.md'], + ['@😀file.ts', '😀file.ts'], + ['😀😀 @src/𠮷file.ts', 'src/𠮷file.ts'], + ['😀 @src/😀file.ts', 'src/😀file.ts'], + ])('uses the complete file query from %s', (text, pattern) => { + renderHook(() => + useCommandCompletion( + useTextBufferForTest(text, Array.from(text).length), + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ), + ); + + expect(useAtCompletion).toHaveBeenLastCalledWith( + expect.objectContaining({ enabled: true, pattern }), + ); + }); + + it('keeps code-point replacement bounds with the cursor inside a Unicode path', async () => { + setupMocks({ + atSuggestions: [{ label: '😀done.ts', value: '😀done.ts' }], + }); + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('😀 @src/😀file.ts tail', 7); + const completion = useCommandCompletion( + textBuffer, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ); + return { ...completion, textBuffer }; + }); + + expect(useAtCompletion).toHaveBeenLastCalledWith( + expect.objectContaining({ enabled: true, pattern: 'src/😀file.ts' }), + ); + await waitFor(() => { + expect(result.current.suggestions).toHaveLength(1); + }); + + act(() => { + result.current.handleAutocomplete(0); + }); + + expect(result.current.textBuffer.text).toBe('😀 @😀done.ts tail'); + }); + it('should switch to AT mode when typing @ after a slash command (#2518)', async () => { setupMocks({ atSuggestions: [{ label: 'src/file.txt', value: 'src/file.txt' }], diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx index a27024faaf7..593e4121420 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -143,7 +143,7 @@ export function useCommandCompletion( } } const pathStart = i + 1; - const partialPath = currentLine.substring(pathStart, end); + const partialPath = codePoints.slice(pathStart, end).join(''); return { completionMode: CompletionMode.AT, query: partialPath, diff --git a/packages/cli/src/ui/opentui/input-prompt-model.test.ts b/packages/cli/src/ui/opentui/input-prompt-model.test.ts index 19f3f7eff91..f589a7aeb78 100644 --- a/packages/cli/src/ui/opentui/input-prompt-model.test.ts +++ b/packages/cli/src/ui/opentui/input-prompt-model.test.ts @@ -97,6 +97,43 @@ const GATING_COMMANDS: readonly SlashCommand[] = [ cmd({ name: 'review' }), ]; +describe('detectCompletionTarget AT queries', () => { + it.each([ + ['hello @README.md', 'README.md'], + ['中文 @文件.ts', '文件.ts'], + ['😀 @README.md', 'README.md'], + ['@😀file.ts', '😀file.ts'], + ['😀😀 @src/𠮷file.ts', 'src/𠮷file.ts'], + ['😀 @src/😀file.ts', 'src/😀file.ts'], + ])('uses the complete file query from %s', (text, query) => { + const cursor = Array.from(text).length; + expect(detectCompletionTarget([text], 0, cursor, text, cursor)).toEqual({ + mode: CompletionMode.AT, + query, + start: Array.from(text).indexOf('@') + 1, + end: cursor, + }); + }); + + it('keeps code-point replacement bounds with the cursor inside a Unicode path', () => { + const text = '😀 @src/😀file.ts tail'; + const target = detectCompletionTarget([text], 0, 7, text, 7); + expect(target).toEqual({ + mode: CompletionMode.AT, + query: 'src/😀file.ts', + start: 3, + end: 15, + }); + const applied = applyCompletion( + text, + target!, + { label: '😀done.ts', value: '😀done.ts' }, + false, + ); + expect(applied.line).toBe('😀 @😀done.ts tail'); + }); +}); + describe('detectCompletionTarget mid-input gating (useCommandCompletion port, R1-86)', () => { const detect = (text: string, cursorOffset: number) => { const lines = text.split('\n'); diff --git a/packages/cli/src/ui/opentui/input-prompt-model.ts b/packages/cli/src/ui/opentui/input-prompt-model.ts index a593bb1bc94..171c6c741a7 100644 --- a/packages/cli/src/ui/opentui/input-prompt-model.ts +++ b/packages/cli/src/ui/opentui/input-prompt-model.ts @@ -216,7 +216,7 @@ export function detectCompletionTarget( const pathStart = i + 1; return { mode: CompletionMode.AT, - query: currentLine.substring(pathStart, end), + query: codePoints.slice(pathStart, end).join(''), start: pathStart, end, };