diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 7b963cfd26a..074490b4c2d 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -6,7 +6,10 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { vi, describe, it, expect, beforeEach } from 'vitest'; -import { useSlashCommandProcessor } from './slashCommandProcessor.js'; +import { + useSlashCommandProcessor, + type SlashCommandProcessorActions, +} from './slashCommandProcessor.js'; import type { CommandContext, ConfirmShellCommandsActionReturn, @@ -121,6 +124,33 @@ describe('useSlashCommandProcessor', () => { }); const mockSettings = {} as LoadedSettings; + const createMockActions = (): SlashCommandProcessorActions => ({ + openAuthDialog: mockOpenAuthDialog, + openArenaDialog: vi.fn(), + openThemeDialog: mockOpenThemeDialog, + openEditorDialog: vi.fn(), + openMemoryDialog: mockOpenMemoryDialog, + openSettingsDialog: vi.fn(), + openModelDialog: mockOpenModelDialog, + openManageModelsDialog: vi.fn(), + openTrustDialog: vi.fn(), + openPermissionsDialog: vi.fn(), + openApprovalModeDialog: vi.fn(), + openResumeDialog: vi.fn(), + handleResume: vi.fn(), + openDeleteDialog: vi.fn(), + quit: mockSetQuittingMessages, + setDebugMessage: vi.fn(), + dispatchExtensionStateUpdate: vi.fn(), + addConfirmUpdateExtensionRequest: vi.fn(), + openSubagentCreateDialog: vi.fn(), + openAgentsManagerDialog: vi.fn(), + openExtensionsManagerDialog: vi.fn(), + openMcpDialog: vi.fn(), + openHooksDialog: vi.fn(), + openRewindSelector: vi.fn(), + }); + beforeEach(() => { vi.clearAllMocks(); vi.mocked(BuiltinCommandLoader).mockClear(); @@ -154,24 +184,7 @@ describe('useSlashCommandProcessor', () => { setIsProcessing, { current: true }, // isIdleRef vi.fn(), // setGeminiMdFileCount - { - openAuthDialog: mockOpenAuthDialog, - openThemeDialog: mockOpenThemeDialog, - openEditorDialog: vi.fn(), - openMemoryDialog: mockOpenMemoryDialog, - openSettingsDialog: vi.fn(), - openModelDialog: mockOpenModelDialog, - openTrustDialog: vi.fn(), - openPermissionsDialog: vi.fn(), - openApprovalModeDialog: vi.fn(), - openResumeDialog: vi.fn(), - quit: mockSetQuittingMessages, - setDebugMessage: vi.fn(), - dispatchExtensionStateUpdate: vi.fn(), - addConfirmUpdateExtensionRequest: vi.fn(), - openSubagentCreateDialog: vi.fn(), - openAgentsManagerDialog: vi.fn(), - }, + createMockActions(), new Map(), // extensionsUpdateState true, // isConfigInitialized null, // logger @@ -270,6 +283,21 @@ describe('useSlashCommandProcessor', () => { ); }); + it('should let slash-prefixed file paths fall through to the model', async () => { + const result = setupProcessorHook(); + await waitFor(() => expect(result.current.slashCommands).toBeDefined()); + + let actionResult; + await act(async () => { + actionResult = await result.current.handleSlashCommand( + '/api/apiFunction/接口的实现', + ); + }); + + expect(actionResult).toBe(false); + expect(mockAddItem).not.toHaveBeenCalled(); + }); + it('should display help for a parent command invoked without a subcommand', async () => { const parentCommand: SlashCommand = { name: 'parent', @@ -968,24 +996,7 @@ describe('useSlashCommandProcessor', () => { vi.fn(), // setIsProcessing { current: true }, // isIdleRef vi.fn(), // setGeminiMdFileCount - { - openAuthDialog: mockOpenAuthDialog, - openThemeDialog: mockOpenThemeDialog, - openEditorDialog: vi.fn(), - openMemoryDialog: mockOpenMemoryDialog, - openSettingsDialog: vi.fn(), - openModelDialog: vi.fn(), - openTrustDialog: vi.fn(), - openPermissionsDialog: vi.fn(), - openApprovalModeDialog: vi.fn(), - openResumeDialog: vi.fn(), - quit: mockSetQuittingMessages, - setDebugMessage: vi.fn(), - dispatchExtensionStateUpdate: vi.fn(), - addConfirmUpdateExtensionRequest: vi.fn(), - openSubagentCreateDialog: vi.fn(), - openAgentsManagerDialog: vi.fn(), - }, + createMockActions(), new Map(), // extensionsUpdateState true, // isConfigInitialized null, // logger diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index f9d685b786e..55586ed0c45 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -46,7 +46,10 @@ import { FileCommandLoader } from '../../services/FileCommandLoader.js'; import { McpPromptLoader } from '../../services/McpPromptLoader.js'; import { SkillCommandLoader } from '../../services/SkillCommandLoader.js'; import { parseSlashCommand } from '../../utils/commands.js'; -import { isBtwCommand } from '../utils/commandUtils.js'; +import { + hasSlashCommandPathSeparator, + isBtwCommand, +} from '../utils/commandUtils.js'; import { clearScreen } from '../../utils/stdioHelpers.js'; import { useKeypress } from './useKeypress.js'; import { @@ -78,7 +81,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([ 'btw', ]); -interface SlashCommandProcessorActions { +export interface SlashCommandProcessorActions { openAuthDialog: () => void; openArenaDialog?: (type: Exclude) => void; openThemeDialog: () => void; @@ -448,6 +451,9 @@ export const useSlashCommandProcessor = ( if (!trimmed.startsWith('/') && !trimmed.startsWith('?')) { return false; } + if (trimmed.startsWith('/') && hasSlashCommandPathSeparator(trimmed)) { + return false; + } const recordedItems: Array> = []; const recordItem = (item: Omit) => { diff --git a/packages/cli/src/ui/utils/commandUtils.test.ts b/packages/cli/src/ui/utils/commandUtils.test.ts index 4273bc1ea76..37c6b33223f 100644 --- a/packages/cli/src/ui/utils/commandUtils.test.ts +++ b/packages/cli/src/ui/utils/commandUtils.test.ts @@ -118,6 +118,15 @@ describe('commandUtils', () => { expect(isSlashCommand('/*\n * Multi-line comment\n */')).toBe(false); expect(isSlashCommand('/*comment without space*/')).toBe(false); }); + + it('should return false for slash-prefixed file paths', () => { + expect(isSlashCommand('/api/apiFunction/接口的实现')).toBe(false); + expect(isSlashCommand('/Users/me/project/src/index.ts')).toBe(false); + expect(isSlashCommand('/var/log/syslog check this')).toBe(false); + expect(isSlashCommand('/home/user/.qwen/settings.json')).toBe(false); + expect(isSlashCommand('/tmp/test.txt')).toBe(false); + expect(isSlashCommand('/tmp\\test.txt')).toBe(false); + }); }); describe('copyToClipboard', () => { diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index 18f74015cb3..ed023b97185 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -38,9 +38,18 @@ export const isAtCommand = (query: string): boolean => // Check if starts with @ OR has a space, then @ query.startsWith('@') || /\s@/.test(query); +const SLASH_PATH_SEPARATOR_RE = /[/\\]/; + +const getSlashCommandFirstToken = (query: string): string => + query.slice(1).trimStart().split(/\s+/)[0] ?? ''; + +export const hasSlashCommandPathSeparator = (query: string): boolean => + SLASH_PATH_SEPARATOR_RE.test(getSlashCommandFirstToken(query)); + /** * Checks if a query string potentially represents an '/' command. - * It triggers if the query starts with '/' but excludes code comments like '//' and '/*'. + * It triggers if the query starts with '/' but excludes code comments like '//' + * and '/*', and file paths where the first token contains a path separator. * * @param query The input query string. * @returns True if the query looks like an '/' command, false otherwise. @@ -60,6 +69,10 @@ export const isSlashCommand = (query: string): boolean => { return false; } + if (hasSlashCommandPathSeparator(query)) { + return false; + } + return true; }; diff --git a/packages/cli/src/ui/utils/historyMapping.test.ts b/packages/cli/src/ui/utils/historyMapping.test.ts index 84c18a2ff03..8f6426a6d95 100644 --- a/packages/cli/src/ui/utils/historyMapping.test.ts +++ b/packages/cli/src/ui/utils/historyMapping.test.ts @@ -208,6 +208,27 @@ describe('computeApiTruncationIndex', () => { // Slash '/help' (id=3) should not be counted expect(computeApiTruncationIndex(ui, 5, api)).toBe(2); }); + + it('counts path-like slash prompts that were sent to the model', () => { + const ui: HistoryItem[] = [ + userItem(1, 'hello'), + geminiItem(2), + userItem(3, '/api/apiFunction/接口的实现'), + geminiItem(4), + userItem(5, 'world'), + geminiItem(6), + ]; + const api: Content[] = [ + userContent('hello'), + modelContent('response 1'), + userContent('/api/apiFunction/接口的实现'), + modelContent('response 2'), + userContent('world'), + modelContent('response 3'), + ]; + + expect(computeApiTruncationIndex(ui, 5, api)).toBe(4); + }); }); describe('single turn', () => { @@ -233,6 +254,15 @@ describe('isRealUserTurn', () => { expect(isRealUserTurn(userItem(1, '/stats'))).toBe(false); }); + it('returns true for path-like slash prompts', () => { + expect(isRealUserTurn(userItem(1, '/api/apiFunction/接口的实现'))).toBe( + true, + ); + expect(isRealUserTurn(userItem(1, '/Users/name/project 帮我安装'))).toBe( + true, + ); + }); + it('returns false for ? commands', () => { expect(isRealUserTurn(userItem(1, '?help'))).toBe(false); }); diff --git a/packages/cli/src/ui/utils/historyMapping.ts b/packages/cli/src/ui/utils/historyMapping.ts index 389acf2e946..6b5bb9d41dd 100644 --- a/packages/cli/src/ui/utils/historyMapping.ts +++ b/packages/cli/src/ui/utils/historyMapping.ts @@ -6,6 +6,7 @@ import type { HistoryItem } from '../types.js'; import type { Content } from '@google/genai'; +import { isSlashCommand } from './commandUtils.js'; /** * Returns true when the history item represents a real user prompt that was @@ -15,7 +16,7 @@ import type { Content } from '@google/genai'; */ export function isRealUserTurn(item: HistoryItem): boolean { if (item.type !== 'user' || !item.text) return false; - return !item.text.startsWith('/') && !item.text.startsWith('?'); + return !isSlashCommand(item.text) && !item.text.startsWith('?'); } /**