diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 7e7f747ddb..a46ab617d6 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -30,6 +30,7 @@ import { SmartRecommendations } from './smart-recommendations'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; import { WorkspaceKind } from '@/shared/types'; import { createImageContextFromFile, createImageContextFromClipboard } from '../utils/imageUtils'; +import { isSlashCommand, stripSlashCommand } from '../utils/slashCommand'; import { notificationService } from '@/shared/notification-system'; import { inputReducer, initialInputState } from '../reducers/inputReducer'; import { modeReducer, initialModeState } from '../reducers/modeReducer'; @@ -1595,11 +1596,11 @@ export const ChatInput: React.FC = ({ inputValueRef.current = text; const localSlashCommandsEnabled = !isAcpInputSession; - const trimmedLower = text.trim().toLowerCase(); - const isBtwCommand = localSlashCommandsEnabled && trimmedLower.startsWith('/btw'); - const isCompactCommand = localSlashCommandsEnabled && trimmedLower.startsWith('/compact'); + const trimmed = text.trim(); + const isBtwCommand = localSlashCommandsEnabled && isSlashCommand(trimmed, '/btw'); + const isCompactCommand = localSlashCommandsEnabled && isSlashCommand(trimmed, '/compact'); const isGoalCommand = localSlashCommandsEnabled && isGoalSlashCommand(text); - const isUsageCommand = localSlashCommandsEnabled && trimmedLower.startsWith('/usage'); + const isUsageCommand = localSlashCommandsEnabled && isSlashCommand(trimmed, '/usage'); const isDeepReviewCommand = localSlashCommandsEnabled && isDeepReviewSlashCommand(text); const isProcessing = !!derivedState?.isProcessing; @@ -1685,7 +1686,7 @@ export const ChatInput: React.FC = ({ const originalPendingLargePastes = { ...pendingLargePastesRef.current }; const message = expandComposerSpecialTokens(originalMessage); const messageCharCount = getCharacterCount(message); - const question = message.replace(/^\/btw\b/i, '').trim(); + const question = stripSlashCommand(message, '/btw').trim(); // Clear input without adding to main history. dispatchInput({ type: 'CLEAR_VALUE' }); @@ -2250,7 +2251,7 @@ export const ChatInput: React.FC = ({ const messageCharCount = getCharacterCount(message); const localSlashCommandsEnabled = !isAcpInputSession; - if (localSlashCommandsEnabled && message.toLowerCase().startsWith('/btw')) { + if (localSlashCommandsEnabled && isSlashCommand(message, '/btw')) { // When idle, /btw can be sent via the normal send button. await submitBtwFromInput(); return; @@ -2291,35 +2292,28 @@ export const ChatInput: React.FC = ({ return; } - if (localSlashCommandsEnabled && message.toLowerCase().startsWith('/compact')) { + if (localSlashCommandsEnabled && isSlashCommand(message, '/compact')) { notificationService.warning( t('chatInput.compactUsage') ); return; } - if (localSlashCommandsEnabled && message.toLowerCase().startsWith('/usage')) { + if (localSlashCommandsEnabled && isSlashCommand(message, '/usage')) { notificationService.warning( t('chatInput.usageCommandUsage') ); return; } - if (localSlashCommandsEnabled && message.toLowerCase().startsWith('/init')) { + if (localSlashCommandsEnabled && isSlashCommand(message, '/init')) { notificationService.warning( t('chatInput.initUsage') ); return; } - if (localSlashCommandsEnabled && message.toLowerCase().startsWith('/goal') && !isGoalSlashCommand(message)) { - notificationService.warning( - t('chatInput.goalUsage') - ); - return; - } - - if (localSlashCommandsEnabled && message.toLowerCase().startsWith('/reload-skills')) { + if (localSlashCommandsEnabled && isSlashCommand(message, '/reload-skills')) { notificationService.warning(t('chatInput.reloadSkillsUsage')); return; } @@ -2467,7 +2461,7 @@ export const ChatInput: React.FC = ({ if (isBtwSession) { return; } - if (!lower.startsWith('/btw')) { + if (!isSlashCommand(lower, '/btw')) { next = '/btw '; } else { // Normalize to "/btw " + rest, preserving any already typed question. @@ -2483,7 +2477,7 @@ export const ChatInput: React.FC = ({ } else if (actionId === 'compact') { next = '/compact'; } else if (actionId === 'goal') { - if (!lower.startsWith('/goal')) { + if (!isSlashCommand(lower, '/goal')) { next = '/goal '; } else { const m = raw.match(/^(\s*)\/goal\b/i); @@ -2814,7 +2808,7 @@ export const ChatInput: React.FC = ({ e.preventDefault(); - const isBtwCommand = inputState.value.trim().toLowerCase().startsWith('/btw'); + const isBtwCommand = isSlashCommand(inputState.value.trim(), '/btw'); if (isBtwCommand) { // Allow /btw submission even while the main session is generating. void submitBtwFromInput(); diff --git a/src/web-ui/src/flow_chat/utils/slashCommand.test.ts b/src/web-ui/src/flow_chat/utils/slashCommand.test.ts new file mode 100644 index 0000000000..59a64dd324 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/slashCommand.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import { + isSlashCommand, + matchesSlashCommand, + stripSlashCommand, +} from './slashCommand'; + +describe('matchesSlashCommand', () => { + it('matches slash command tokens at a whitespace boundary', () => { + expect(matchesSlashCommand('/goal focus the bug')).toBe('/goal'); + expect(matchesSlashCommand('/btw\tquestion')).toBe('/btw'); + expect(matchesSlashCommand('/usage\n')).toBe('/usage'); + expect(matchesSlashCommand('/mcp:foo-bar arg')).toBe('/mcp:foo-bar'); + }); + + it('rejects text without a leading slash command token', () => { + expect(matchesSlashCommand('')).toBeNull(); + expect(matchesSlashCommand('hello')).toBeNull(); + expect(matchesSlashCommand(' /goal fix bug')).toBeNull(); + expect(matchesSlashCommand('/123')).toBeNull(); + expect(matchesSlashCommand('/-goal')).toBeNull(); + }); + + it('does not conflate longer commands with a shorter prefix', () => { + expect(matchesSlashCommand('/goals')).toBe('/goals'); + expect(matchesSlashCommand('/btwextra')).toBe('/btwextra'); + expect(matchesSlashCommand('/usage2')).toBe('/usage2'); + }); +}); + +describe('isSlashCommand', () => { + it('matches exact command tokens case-insensitively', () => { + expect(isSlashCommand('/btw', '/btw')).toBe(true); + expect(isSlashCommand('/BTW hello', '/btw')).toBe(true); + expect(isSlashCommand('/goal\nfix bug', '/goal')).toBe(true); + }); + + it('rejects prefix-only and unrelated matches', () => { + expect(isSlashCommand('/btwextra', '/btw')).toBe(false); + expect(isSlashCommand('/compactor', '/compact')).toBe(false); + expect(isSlashCommand('hello', '/btw')).toBe(false); + }); + + it('rejects invalid command definitions and non-string text', () => { + expect(isSlashCommand('/btw', 'btw' as unknown as `/${string}`)).toBe(false); + expect(isSlashCommand(null as unknown as string, '/btw')).toBe(false); + }); +}); + +describe('stripSlashCommand', () => { + it('removes the command token and immediate whitespace', () => { + expect(stripSlashCommand('/btw question?', '/btw')).toBe('question?'); + expect(stripSlashCommand('/btw\tquestion?', '/btw')).toBe('question?'); + expect(stripSlashCommand('/btw\nquestion?', '/btw')).toBe('question?'); + expect(stripSlashCommand('/btw', '/btw')).toBe(''); + }); + + it('preserves case-insensitive matches', () => { + expect(stripSlashCommand('/BTW hello', '/btw')).toBe('hello'); + }); + + it('returns the original string when the command does not match', () => { + expect(stripSlashCommand('/btwextra', '/btw')).toBe('/btwextra'); + expect(stripSlashCommand('hello', '/btw')).toBe('hello'); + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/slashCommand.ts b/src/web-ui/src/flow_chat/utils/slashCommand.ts new file mode 100644 index 0000000000..e485c03ed2 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/slashCommand.ts @@ -0,0 +1,31 @@ +const COMMAND_BOUNDARY_RE = /^(\/[A-Za-z][\w:-]*)(?=\s|$)/; + +export function matchesSlashCommand(text: string): string | null { + if (typeof text !== 'string' || text.length === 0 || !text.startsWith('/')) { + return null; + } + + const match = text.match(COMMAND_BOUNDARY_RE); + return match ? match[1].toLowerCase() : null; +} + +export function isSlashCommand(text: string, command: string): boolean { + if (typeof command !== 'string' || !command.startsWith('/')) { + return false; + } + + return matchesSlashCommand(text) === command.toLowerCase(); +} + +export function stripSlashCommand(text: string, command: string): string { + const normalizedCommand = + typeof command === 'string' && command.startsWith('/') + ? command.toLowerCase() + : null; + + if (!normalizedCommand || matchesSlashCommand(text) !== normalizedCommand) { + return text; + } + + return text.slice(normalizedCommand.length).replace(/^\s*/, ''); +}