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
25 changes: 15 additions & 10 deletions src/web-ui/src/flow_chat/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1315,10 +1316,10 @@ export const ChatInput: React.FC<ChatInputProps> = ({
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;

Expand Down Expand Up @@ -1944,7 +1945,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
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;
Expand Down Expand Up @@ -1980,21 +1981,21 @@ export const ChatInput: React.FC<ChatInputProps> = ({
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.' })
);
Expand Down Expand Up @@ -2152,7 +2153,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({
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.
Expand All @@ -2168,7 +2173,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
} 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);
Expand Down Expand Up @@ -2468,7 +2473,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({

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();
Expand Down
140 changes: 140 additions & 0 deletions src/web-ui/src/flow_chat/utils/slashCommand.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
92 changes: 92 additions & 0 deletions src/web-ui/src/flow_chat/utils/slashCommand.ts
Original file line number Diff line number Diff line change
@@ -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]*`), '');
}
Loading