Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ tmp/

# Documentation
docs/_build/
docs/tmp
site/

# Node.js (TUI)
Expand Down
17 changes: 17 additions & 0 deletions webui/src/components/common/CommandDropdown.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
13 changes: 9 additions & 4 deletions webui/src/components/common/CommandDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
};
}
28 changes: 27 additions & 1 deletion webui/src/components/common/SessionChat.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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');
Expand Down
13 changes: 9 additions & 4 deletions webui/src/components/common/SessionChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down