diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 3361db881..ddc85d833 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -26,6 +26,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 } from '../utils/slashCommand'; import { notificationService } from '@/shared/notification-system'; import { inputReducer, initialInputState } from '../reducers/inputReducer'; import { modeReducer, initialModeState } from '../reducers/modeReducer'; @@ -1315,10 +1316,10 @@ export const ChatInput: React.FC = ({ inputValueRef.current = text; const trimmedLower = text.trim().toLowerCase(); - const isBtwCommand = trimmedLower.startsWith('/btw'); - const isCompactCommand = trimmedLower.startsWith('/compact'); + const isBtwCommand = isSlashCommand(trimmedLower, '/btw'); + const isCompactCommand = isSlashCommand(trimmedLower, '/compact'); const isGoalCommand = isGoalSlashCommand(text); - const isUsageCommand = trimmedLower.startsWith('/usage'); + const isUsageCommand = isSlashCommand(trimmedLower, '/usage'); const isDeepReviewCommand = isDeepReviewSlashCommand(text); const isProcessing = !!derivedState?.isProcessing; @@ -1944,7 +1945,7 @@ export const ChatInput: React.FC = ({ const message = expandComposerSpecialTokens(originalMessage); const messageCharCount = getCharacterCount(message); - if (message.toLowerCase().startsWith('/btw')) { + if (isSlashCommand(message, '/btw')) { // When idle, /btw can be sent via the normal send button. await submitBtwFromInput(); return; @@ -1980,21 +1981,21 @@ export const ChatInput: React.FC = ({ return; } - if (message.toLowerCase().startsWith('/compact')) { + if (isSlashCommand(message, '/compact')) { notificationService.warning( t('chatInput.compactUsage', { defaultValue: 'Use /compact without extra arguments.' }) ); return; } - if (message.toLowerCase().startsWith('/usage')) { + if (isSlashCommand(message, '/usage')) { notificationService.warning( t('chatInput.usageCommandUsage', { defaultValue: 'Use /usage without extra arguments.' }) ); return; } - if (message.toLowerCase().startsWith('/init')) { + if (isSlashCommand(message, '/init')) { notificationService.warning( t('chatInput.initUsage', { defaultValue: 'Use /init without extra arguments.' }) ); @@ -2152,7 +2153,11 @@ export const ChatInput: React.FC = ({ if (isBtwSession) { return; } - if (!lower.startsWith('/btw')) { + // Use precise slash-command matcher (enforces word boundary and + // treats tab/newline as valid trailing whitespace — Claude Code 2.1.147) + // instead of the loose `lower.startsWith('/btw')` that used to also + // accept /btwextra. + if (!isSlashCommand(lower, '/btw')) { next = '/btw '; } else { // Normalize to "/btw " + rest, preserving any already typed question. @@ -2168,7 +2173,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); @@ -2468,7 +2473,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 000000000..7a5470f76 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/slashCommand.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest'; + +import { + isSlashCommand, + matchesSlashCommand, + stripSlashCommand, +} from './slashCommand'; + +describe('matchesSlashCommand', () => { + it('returns null for empty / non-string input', () => { + expect(matchesSlashCommand('')).toBeNull(); + expect(matchesSlashCommand(' ')).toBeNull(); + expect(matchesSlashCommand(null as unknown as string)).toBeNull(); + expect(matchesSlashCommand(undefined as unknown as string)).toBeNull(); + expect(matchesSlashCommand(123 as unknown as string)).toBeNull(); + }); + + it('returns null for text not starting with /', () => { + expect(matchesSlashCommand('hello')).toBeNull(); + expect(matchesSlashCommand('not a command')).toBeNull(); + }); + + it('ignores leading whitespace (does not match)', () => { + // Users typically type the command without leading spaces; if they do, + // the helper does not pretend it sees a slash command — the caller + // should have already trimmed via `inputState.value.trim()` first. + expect(matchesSlashCommand(' /goal focus')).toBeNull(); + }); + + it('returns the matched command token for plain commands', () => { + expect(matchesSlashCommand('/goal focus the bug')).toBe('/goal'); + expect(matchesSlashCommand('/btw question?')).toBe('/btw'); + expect(matchesSlashCommand('/usage')).toBe('/usage'); + expect(matchesSlashCommand('/DeepReview')).toBe('/deepreview'); + }); + + it('treats trailing tab / newline as a valid boundary (Claude Code 2.1.147)', () => { + expect(matchesSlashCommand('/goal focus\t')).toBe('/goal'); + expect(matchesSlashCommand('/goal focus\n')).toBe('/goal'); + expect(matchesSlashCommand('/goal focus\r\n')).toBe('/goal'); + expect(matchesSlashCommand('/goal\t\n ')).toBe('/goal'); + expect(matchesSlashCommand('/btw\n')).toBe('/btw'); + expect(matchesSlashCommand('/btw\tnext line')).toBe('/btw'); + }); + + it('does not conflate a prefix with another command', () => { + // Without the word-boundary fix, /goals / /btwextra etc. would have + // matched `/goal` / `/btw`. They must not. + expect(matchesSlashCommand('/goals')).toBe('/goals'); + expect(matchesSlashCommand('/btwextra')).toBe('/btwextra'); + expect(matchesSlashCommand('/usage2')).toBe('/usage2'); + }); + + it('supports /-prefixed names containing : and - (MCP prompt commands)', () => { + expect(matchesSlashCommand('/mcp:foo-bar arg')).toBe('/mcp:foo-bar'); + expect(matchesSlashCommand('/mcp:foo-bar')).toBe('/mcp:foo-bar'); + }); + + it('returns null for slash followed by a non-letter', () => { + expect(matchesSlashCommand('/123')).toBeNull(); + expect(matchesSlashCommand('/-cmd')).toBeNull(); + }); +}); + +describe('isSlashCommand', () => { + it('matches the exact command', () => { + expect(isSlashCommand('/btw hello', '/btw')).toBe(true); + expect(isSlashCommand('/btw', '/btw')).toBe(true); + expect(isSlashCommand('/btw\t', '/btw')).toBe(true); + expect(isSlashCommand('/btw\nnext line', '/btw')).toBe(true); + }); + + it('rejects prefix-only matches', () => { + expect(isSlashCommand('/btwextra', '/btw')).toBe(false); + expect(isSlashCommand('/btwsomething', '/btw')).toBe(false); + }); + + it('rejects unrelated commands', () => { + expect(isSlashCommand('/goal focus', '/btw')).toBe(false); + expect(isSlashCommand('hello', '/btw')).toBe(false); + }); + + it('rejects an invalid command argument', () => { + expect(isSlashCommand('/btw', 'btw' as unknown as `/${string}`)).toBe(false); + expect(isSlashCommand('/btw', 'no-slash' as unknown as `/${string}`)).toBe(false); + }); + + it('is case-insensitive on the command name', () => { + expect(isSlashCommand('/BTW hello', '/btw')).toBe(true); + expect(isSlashCommand('/Btw hello', '/btw')).toBe(true); + }); + + it('is robust against non-string inputs (defensive)', () => { + // isSlashCommand should never throw on a non-string text argument. + expect(isSlashCommand(null as unknown as string, '/btw')).toBe(false); + expect(isSlashCommand(undefined as unknown as string, '/btw')).toBe(false); + expect(isSlashCommand(123 as unknown as string, '/btw')).toBe(false); + }); +}); + +describe('stripSlashCommand', () => { + it('strips the command and the following whitespace, leaving the argument', () => { + expect(stripSlashCommand('/btw question?', '/btw')).toBe('question?'); + expect(stripSlashCommand('/btw question?', '/btw')).toBe('question?'); + expect(stripSlashCommand('/btw\tquestion?', '/btw')).toBe('question?'); + expect(stripSlashCommand('/btw\nquestion?', '/btw')).toBe('question?'); + }); + + it('returns empty string when the command has no argument', () => { + expect(stripSlashCommand('/btw', '/btw')).toBe(''); + expect(stripSlashCommand('/btw\t', '/btw')).toBe(''); + }); + + it('does not strip when the prefix does not match', () => { + expect(stripSlashCommand('/btwextra', '/btw')).toBe('/btwextra'); + expect(stripSlashCommand('hello', '/btw')).toBe('hello'); + }); + + it('escapes regex metacharacters in the command', () => { + expect(stripSlashCommand('/mcp:foo-bar arg', '/mcp:foo-bar')).toBe('arg'); + expect(stripSlashCommand('/mcp:foo-bar', '/mcp:foo-bar')).toBe(''); + }); + + it('truly escapes regex metacharacters (the alternation `|` case)', () => { + // The `:foo-bar` test above doesn't actually exercise the escape because + // `:` and `-` are not regex metacharacters. The character class used by + // matchesSlashCommand (`[\w:-]`) excludes every regex metachar, so + // isSlashCommand will reject any command that contains one before + // stripSlashCommand even runs. The escape therefore is purely defensive + // — stripSlashCommand must not throw on a hand-crafted command. We can't + // call it through the public surface, but the internal escape is exercised + // by a smoke check that the function returns the original string for + // commands it would reject. + expect(stripSlashCommand('not-prefixed', '|')).toBe('not-prefixed'); + }); + + it('leaves the body untouched when only the command body has mixed whitespace', () => { + expect(stripSlashCommand('/btw \t \n arg', '/btw')).toBe('arg'); + }); +}); 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 000000000..d4a750f74 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/slashCommand.ts @@ -0,0 +1,92 @@ +/** + * Slash-command matching helpers for the chat composer. + * + * Mirrors Claude Code 2.1.147: a slash command followed by trailing + * whitespace (including tabs and newlines) is still recognised as that + * command, never "an unknown command". Trailing `tab` and `newline` are + * valid word boundaries in the strict sense, so a literal-prefix check + * (`text.startsWith('/btw')`) already happens to accept them, but the + * same check also accidentally accepts `/btwextra`, `/goals`, etc. — + * commands with the same prefix. The helpers in this module enforce a + * proper word boundary so e.g. `/btwextra` is NOT treated as `/btw`. + * + * Use these in preference to `text.startsWith('/xxx')` everywhere the + * chat input decides which slash command a typed line is. + */ + +const COMMAND_BOUNDARY_RE = /^(\/[a-zA-Z][\w:-]*)(?=\s|$)/; + +/** + * Returns the matched command token (lowercase, with leading `/`) if + * `text` begins with a recognised slash command, otherwise null. + * + * A "recognised" command is one whose name starts with a `/` followed by + * a letter and contains only word characters (`[a-zA-Z0-9_:-]`) up to + * the first whitespace or end-of-string boundary. This is the same + * boundary rule Claude Code uses to detect slash commands. + * + * Examples: + * matchesSlashCommand('/goal focus the bug') -> '/goal' + * matchesSlashCommand('/goal\t') -> '/goal' (2.1.147) + * matchesSlashCommand('/goal\nnext line') -> '/goal' (2.1.147) + * matchesSlashCommand('/goals') -> '/goals' (NOT '/goal') + * matchesSlashCommand('/btwextra') -> '/btwextra' + * matchesSlashCommand('hello') -> null + * matchesSlashCommand('') -> null + */ +export function matchesSlashCommand(text: string): string | null { + if (typeof text !== 'string' || text.length === 0) { + return null; + } + // Do NOT trim leading whitespace here — the caller is expected to have + // already done so (see e.g. `inputState.value.trim()` in ChatInput.tsx). + // A line that is only whitespace or starts with anything other than `/` + // is not a slash command. + if (!text.startsWith('/')) { + return null; + } + const match = text.match(COMMAND_BOUNDARY_RE); + return match ? match[1].toLowerCase() : null; +} + +/** + * Convenience predicate: does `text` start with the given slash command? + * + * Examples: + * isSlashCommand('/btw hello', '/btw') -> true + * isSlashCommand('/btw', '/btw') -> true + * isSlashCommand('/btw\t', '/btw') -> true + * isSlashCommand('/btwextra', '/btw') -> false + * isSlashCommand('not a command', '/btw') -> false + */ +export function isSlashCommand(text: string, command: string): boolean { + if (typeof command !== 'string' || !command.startsWith('/')) { + return false; + } + const matched = matchesSlashCommand(text); + return matched === command.toLowerCase(); +} + +/** + * Strip the leading `/cmd` token (and any whitespace right after it) from + * `text`, returning the remainder. If `text` does not start with `command`, + * the original string is returned unchanged. All leading whitespace after + * the command is consumed (including tabs and newlines — the 2.1.147 + * case) so callers receive just the argument. + * + * Examples: + * stripSlashCommand('/btw\tquestion', '/btw') -> 'question' + * stripSlashCommand('/btw question', '/btw') -> 'question' + * stripSlashCommand('/btw', '/btw') -> '' + * stripSlashCommand('/btwextra', '/btw') -> '/btwextra' (unchanged) + */ +export function stripSlashCommand(text: string, command: string): string { + if (!isSlashCommand(text, command)) { + return text; + } + // text starts with /cmd followed by either whitespace or end-of-string. + // Consume the command and any leading whitespace (including tab/newline, + // the 2.1.147 case) so callers see only the argument. + const escaped = command.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return text.replace(new RegExp(`^${escaped}[\\s]*`), ''); +}