diff --git a/.gitignore b/.gitignore index 344937d9..59fa2c7c 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,7 @@ tmp/ # Documentation docs/_build/ +docs/tmp site/ # Node.js (TUI) diff --git a/webui/src/components/common/CommandDropdown.test.ts b/webui/src/components/common/CommandDropdown.test.ts new file mode 100644 index 00000000..ab1b57a9 --- /dev/null +++ b/webui/src/components/common/CommandDropdown.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { parseSlashCommand } from './CommandDropdown'; + +describe('parseSlashCommand', () => { + it('parses normal slash commands', () => { + expect(parseSlashCommand('/help')).toEqual({ command: 'help', args: '' }); + expect(parseSlashCommand('/plan build the thing')).toEqual({ + command: 'plan', + args: 'build the thing', + }); + }); + + it('does not treat absolute filesystem paths as commands', () => { + expect(parseSlashCommand('/tmp/workspace/workflow.md')).toBeNull(); + expect(parseSlashCommand('/tmp/rex_integration_guide.md\n\nuse this')).toBeNull(); + }); +}); diff --git a/webui/src/components/common/CommandDropdown.tsx b/webui/src/components/common/CommandDropdown.tsx index 11499610..2138ce0f 100644 --- a/webui/src/components/common/CommandDropdown.tsx +++ b/webui/src/components/common/CommandDropdown.tsx @@ -89,16 +89,21 @@ export default function CommandDropdown({ * 从输入文本中解析 slash 命令的名称和参数 * 例如 "/plan create a feature" → { command: "plan", args: "create a feature" } */ +export function isSlashCommandName(command: string): boolean { + return command.length > 0 && !/[\\/]/.test(command); +} + export function parseSlashCommand(text: string): { command: string; args: string } | null { const trimmed = text.trim(); if (!trimmed.startsWith('/')) return null; const withoutSlash = trimmed.slice(1); const spaceIndex = withoutSlash.indexOf(' '); - if (spaceIndex === -1) { - return { command: withoutSlash, args: '' }; + const command = spaceIndex === -1 ? withoutSlash : withoutSlash.slice(0, spaceIndex); + if (!isSlashCommandName(command)) { + return null; } return { - command: withoutSlash.slice(0, spaceIndex), - args: withoutSlash.slice(spaceIndex + 1).trim(), + command, + args: spaceIndex === -1 ? '' : withoutSlash.slice(spaceIndex + 1).trim(), }; } diff --git a/webui/src/components/common/SessionChat.test.ts b/webui/src/components/common/SessionChat.test.ts index e26218a3..ab9a8f6c 100644 --- a/webui/src/components/common/SessionChat.test.ts +++ b/webui/src/components/common/SessionChat.test.ts @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -461,6 +461,32 @@ describe('SessionChat agent mentions', () => { }); }); +describe('SessionChat slash command routing', () => { + it('sends absolute filesystem paths as normal prompts instead of slash commands', async () => { + render(React.createElement(SessionChat, { + sessionId: 'sess-1', + })); + + const text = '/tmp/stream_alert_denoise/rex_integration_guide.md\n\nuse this file'; + const textarea = screen.getByPlaceholderText('请输入消息') as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: text } }); + fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' }); + + await waitFor(() => { + expect(clientPostMock).toHaveBeenCalledWith( + '/api/session/sess-1/prompt_async', + expect.objectContaining({ + parts: [{ type: 'text', text }], + }), + ); + }); + expect(clientPostMock).not.toHaveBeenCalledWith( + '/api/session/sess-1/command', + expect.anything(), + ); + }); +}); + describe('truncateToolDisplayText', () => { it('returns short text unchanged', () => { expect(truncateToolDisplayText('bash')).toBe('bash'); diff --git a/webui/src/components/common/SessionChat.tsx b/webui/src/components/common/SessionChat.tsx index fe648e24..799f94e8 100644 --- a/webui/src/components/common/SessionChat.tsx +++ b/webui/src/components/common/SessionChat.tsx @@ -23,7 +23,7 @@ import { useTranslation } from 'react-i18next'; import LoadingSpinner from './LoadingSpinner'; import { QuestionTool } from './QuestionTool'; import DelegateTaskCard, { isDelegateTool, shouldRenderDelegateTaskCard } from './DelegateTaskCard'; -import CommandDropdown, { parseSlashCommand } from './CommandDropdown'; +import CommandDropdown, { isSlashCommandName, parseSlashCommand } from './CommandDropdown'; import ImageLightbox from './ImageLightbox'; import { useSessionMessages } from '@/hooks/useSessions'; import { useSSE, type SSEConnectionStatus } from '@/hooks/useSSE'; @@ -2415,15 +2415,20 @@ export default function SessionChat({ const cursor = e.target.selectionStart ?? val.length; const mention = mentionAgents.length > 0 ? findMentionTrigger(val, cursor) : null; const trimmed = val.trimStart(); + const slashQuery = trimmed.startsWith('/') ? trimmed.slice(1) : ''; if (mention && !trimmed.startsWith('/')) { setMentionRange({ start: mention.start, end: mention.end }); setMentionQuery(mention.query); setSelectedMentionIndex(0); setShowCommandDropdown(false); - } else if (trimmed.startsWith('/') && !trimmed.includes(' ') && successfulAttachments.length === 0) { + } else if ( + trimmed.startsWith('/') && + !trimmed.includes(' ') && + (slashQuery === '' || isSlashCommandName(slashQuery)) && + successfulAttachments.length === 0 + ) { void loadCommandsIfNeeded(); - const q = trimmed.slice(1); - setCommandQuery(q); + setCommandQuery(slashQuery); setSelectedCommandIndex(0); setShowCommandDropdown(true); setMentionRange(null);