Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
3 changes: 2 additions & 1 deletion packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2240,7 +2240,8 @@ export const AppContainer = (props: AppContainerProps) => {
if (dialogsVisible) return;
if (messageQueue.length === 0) return;

// Two-phase: batch plain prompts as one turn, else pop next slash command.
// Batch the leading plain-prompt run; if the queue starts with a slash
// command, submit that segment on its own to preserve typed order.
const plainPrompts = drainQueue();
const submission =
plainPrompts.length > 0 ? plainPrompts.join('\n\n') : popNextSegment();
Expand Down
166 changes: 166 additions & 0 deletions packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,24 @@ describe('useSlashCommandProcessor', () => {
);
});

it('should keep a bare slash in the slash command flow', async () => {
const result = setupProcessorHook();
await waitFor(() => expect(result.current.slashCommands).toBeDefined());

const commandResult = await act(async () =>
result.current.handleSlashCommand('/'),
);

expect(commandResult).toEqual({ type: 'handled' });
expect(mockAddItem).toHaveBeenLastCalledWith(
{
type: MessageType.ERROR,
text: 'Unknown command: /',
},
expect.any(Number),
);
});

it('should display help for a parent command invoked without a subcommand', async () => {
const parentCommand: SlashCommand = {
name: 'parent',
Expand Down Expand Up @@ -829,6 +847,23 @@ describe('useSlashCommandProcessor', () => {
);
});

it('should correctly match the help alias via slash prefix', async () => {
const action = vi.fn();
const command = createTestCommand({
name: 'help',
altNames: ['?'],
action,
});
const result = setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));

await act(async () => {
await result.current.handleSlashCommand('/?');
});

expect(action).toHaveBeenCalledTimes(1);
});

it('should handle extra whitespace around the command', async () => {
const action = vi.fn();
const command = createTestCommand({ name: 'test', action });
Expand All @@ -842,6 +877,137 @@ describe('useSlashCommandProcessor', () => {
expect(action).toHaveBeenCalledWith(expect.anything(), 'with-args');
});

it('should execute Unicode custom commands', async () => {
const action = vi.fn();
const command = createTestCommand({ name: '文档', action });
const result = setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));

await act(async () => {
await result.current.handleSlashCommand('/文档 查看内容');
});

expect(action).toHaveBeenCalledWith(expect.anything(), '查看内容');
});

it('should execute extension-qualified commands with dots', async () => {
const action = vi.fn();
const command = createTestCommand({ name: 'gcp.deploy', action });
const result = setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));

await act(async () => {
await result.current.handleSlashCommand('/gcp.deploy with-args');
});

expect(action).toHaveBeenCalledWith(expect.anything(), 'with-args');
});

it('should execute loaded commands with filename punctuation', async () => {
const action = vi.fn();
const command = createTestCommand({ name: 'deploy@prod', action });
const result = setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));

await act(async () => {
await result.current.handleSlashCommand('/deploy@prod with-args');
});

expect(action).toHaveBeenCalledWith(expect.anything(), 'with-args');
});

it('should execute loaded commands before falling back for slash-prefixed input with args', async () => {
const action = vi.fn();
const command = createTestCommand({ name: 'tmp', action });
const result = setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));

await act(async () => {
await result.current.handleSlashCommand('/tmp inspect');
});

expect(action).toHaveBeenCalledWith(expect.anything(), 'inspect');
expect(mockAddItem).not.toHaveBeenCalledWith(
expect.objectContaining({ type: MessageType.ERROR }),
expect.any(Number),
);
});

it('should return false for file-path-like input (issue #1804)', async () => {
const result = setupProcessorHook();
await waitFor(() => expect(result.current.slashCommands).toBeDefined());

// File paths should not enter the slash command flow when the first
// token contains a path separator.
const pathResult = await act(async () =>
result.current.handleSlashCommand('/api/apiFunction/接口的实现'),
);
expect(pathResult).toBe(false);

const absPathResult = await act(async () =>
result.current.handleSlashCommand(
'/Users/zhoushuo/Desktop/dw-operator-skill 帮我安装',
),
);
expect(absPathResult).toBe(false);
});

it('should return false for unknown slash-prefixed input with arguments', async () => {
const result = setupProcessorHook();
await waitFor(() => expect(result.current.slashCommands).toBeDefined());

const tmpResult = await act(async () =>
result.current.handleSlashCommand('/tmp inspect'),
);
const etcResult = await act(async () =>
result.current.handleSlashCommand('/etc explain'),
);
const readmeResult = await act(async () =>
result.current.handleSlashCommand('/README.md summarize'),
);
const dataResult = await act(async () =>
result.current.handleSlashCommand('/data foo'),
);
const vendorResult = await act(async () =>
result.current.handleSlashCommand('/vendor explain'),
);
const workspaceResult = await act(async () =>
result.current.handleSlashCommand('/workspace check'),
);

expect(tmpResult).toBe(false);
expect(etcResult).toBe(false);
expect(readmeResult).toBe(false);
expect(dataResult).toBe(false);
expect(vendorResult).toBe(false);
expect(workspaceResult).toBe(false);
expect(mockAddItem).not.toHaveBeenCalled();
});

it('should return false for slash-prefixed shell-metacharacter input', async () => {
const result = setupProcessorHook();
await waitFor(() => expect(result.current.slashCommands).toBeDefined());

const envResult = await act(async () =>
result.current.handleSlashCommand('/$HOME'),
);
const bangResult = await act(async () =>
result.current.handleSlashCommand('/!important'),
);
const pipeResult = await act(async () =>
result.current.handleSlashCommand('/|pipe'),
);
const semicolonResult = await act(async () =>
result.current.handleSlashCommand('/;comment'),
);

expect(envResult).toBe(false);
expect(bangResult).toBe(false);
expect(pipeResult).toBe(false);
expect(semicolonResult).toBe(false);
expect(mockAddItem).not.toHaveBeenCalled();
});

it('should handle `?` as a command prefix', async () => {
const action = vi.fn();
const command = createTestCommand({ name: 'help', action });
Expand Down
37 changes: 36 additions & 1 deletion packages/cli/src/ui/hooks/slashCommandProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import { FileCommandLoader } from '../../services/FileCommandLoader.js';
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
import { SkillCommandLoader } from '../../services/SkillCommandLoader.js';
import { parseSlashCommand } from '../../utils/commands.js';
import { isBtwCommand } from '../utils/commandUtils.js';
import { isBtwCommand, looksLikeCommandName } from '../utils/commandUtils.js';
import { clearScreen } from '../../utils/stdioHelpers.js';
import { useKeypress } from './useKeypress.js';
import {
Expand Down Expand Up @@ -78,6 +78,20 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([
'btw',
]);

function getSlashCommandParts(query: string): string[] {
const commandText = query.slice(1).trim();
return commandText ? commandText.split(/\s+/) : [];
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
}

function isKnownTopLevelCommandToken(
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
token: string,
commands: readonly SlashCommand[],
): boolean {
return commands.some(
(command) => command.name === token || command.altNames?.includes(token),
);
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
}

interface SlashCommandProcessorActions {
openAuthDialog: () => void;
openArenaDialog?: (type: Exclude<ArenaDialogType, null>) => void;
Expand Down Expand Up @@ -449,6 +463,27 @@ export const useSlashCommandProcessor = (
return false;
}

// For '/' prefix, only keep command-shaped inputs in the slash command
// flow. Inputs with path separators, such as '/api/endpoint', should be
// sent to the model as regular text.
if (trimmed.startsWith('/')) {
const commandParts = getSlashCommandParts(trimmed);
const firstToken = commandParts[0] ?? '';
if (firstToken && !looksLikeCommandName(firstToken)) {
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
return false;
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
}
// Loaded commands and aliases win. Otherwise, slash-prefixed input
// with arguments is ambiguous with root-level paths such as
// "/data foo", so let the model handle it instead of reporting
// "Unknown command" and dropping the user's content.
if (
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
commandParts.length > 1 &&
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
!isKnownTopLevelCommandToken(firstToken, commands)
) {
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
return false;
}
}

const recordedItems: Array<Omit<HistoryItem, 'id'>> = [];
const recordItem = (item: Omit<HistoryItem, 'id'>) => {
recordedItems.push(item);
Expand Down
42 changes: 39 additions & 3 deletions packages/cli/src/ui/hooks/useMessageQueue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ describe('useMessageQueue', () => {
expect(drained).toEqual([]);
});

it('drains all plain-text messages and leaves slash commands queued', () => {
it('drains leading plain-text messages and preserves later queue order', () => {
const { result } = renderHook(() => useMessageQueue());

act(() => {
Expand All @@ -180,8 +180,44 @@ describe('useMessageQueue', () => {
drained = result.current.drainQueue();
});

expect(drained).toEqual(['one', 'two', 'three']);
expect(result.current.messageQueue).toEqual(['/model']);
expect(drained).toEqual(['one', 'two']);
expect(result.current.messageQueue).toEqual(['/model', 'three']);
});

it('does not reorder slash-like prompts ahead of following plain text', () => {
const { result } = renderHook(() => useMessageQueue());

act(() => {
result.current.addMessage('hello');
result.current.addMessage('/data foo');
result.current.addMessage('world');
});

let drained: string[] = [];
act(() => {
drained = result.current.drainQueue();
});

expect(drained).toEqual(['hello']);
expect(result.current.messageQueue).toEqual(['/data foo', 'world']);
});

it('drains shell-metacharacter slash-like prompts as plain text', () => {
const { result } = renderHook(() => useMessageQueue());

act(() => {
result.current.addMessage('hello');
result.current.addMessage('/$HOME');
result.current.addMessage('world');
});

let drained: string[] = [];
act(() => {
drained = result.current.drainQueue();
});

expect(drained).toEqual(['hello', '/$HOME', 'world']);
expect(result.current.messageQueue).toEqual([]);
});

it('returns an empty array when the queue contains only slash commands', () => {
Expand Down
14 changes: 10 additions & 4 deletions packages/cli/src/ui/hooks/useMessageQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export interface UseMessageQueueReturn {
getQueuedMessagesText: () => string;
/** Drain the entire queue joined with `\n\n`. For Ctrl+C / ESC / Up edit-restore. */
popAllMessages: () => string | null;
/** Drain plain-text prompts; leave slash commands queued. Safe from non-React callbacks. */
/** Drain leading plain-text prompts; stop before slash commands to preserve order. */
drainQueue: () => string[];
/** Pop the first item from the queue. */
popNextSegment: () => string | null;
Expand Down Expand Up @@ -54,9 +54,15 @@ export function useMessageQueue(): UseMessageQueueReturn {
const drainQueue = useCallback((): string[] => {
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
const current = queueRef.current;
if (current.length === 0) return [];
const drained = current.filter((message) => !isSlashCommand(message));
if (drained.length === 0) return [];
const rest = current.filter((message) => isSlashCommand(message));

const firstSlashIndex = current.findIndex((message) =>
isSlashCommand(message),
);
const drainEnd = firstSlashIndex === -1 ? current.length : firstSlashIndex;
if (drainEnd === 0) return [];

const drained = current.slice(0, drainEnd);
const rest = current.slice(drainEnd);
queueRef.current = rest;
setMessageQueue(rest);
return drained;
Expand Down
Loading
Loading