mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Expose Assistant fixture commands
This commit is contained in:
@@ -697,6 +697,14 @@ timers when the stream already has a newer workflow/tool state.
|
||||
the real `provider_retry` workflow state, including attempt metadata and
|
||||
`retry_after_ms`, so browser proof of retry countdown behavior does not
|
||||
depend on external provider availability or API spend.
|
||||
Dev/test fixture prompts are also part of the Assistant command-discovery
|
||||
surface: slash autocomplete and Assistant command help must expose an
|
||||
insertable `/fixture` command in development/test builds, search it by
|
||||
canonical fixture names such as `provider-retry`, and submit completed
|
||||
`/fixture <name>` prompts through the normal chat send path so
|
||||
`maybeRunAIChatDevStreamFixture` remains the single local execution boundary.
|
||||
Production command surfaces must omit that dev command instead of advertising
|
||||
a fixture mode that the runtime will not intercept.
|
||||
The local `stream-idle` fixture must exercise the real `stream_idle`
|
||||
workflow state after selected-provider startup so browser proof of visible
|
||||
idle liveness does not depend on making a real provider pause on demand.
|
||||
|
||||
@@ -134,6 +134,10 @@ Assistant local stream fixtures are part of the same frontend API contract:
|
||||
`frontend-modern/src/api/aiChatDevStreamFixture.ts` may short-circuit only
|
||||
explicit `/fixture ...` prompts in development or test mode, must emit the same
|
||||
typed stream event sequence as live chat, and must never open a provider request.
|
||||
That helper also owns the exported fixture-name and alias catalog consumed by
|
||||
Assistant command discovery; frontend command surfaces may search and insert
|
||||
`/fixture` from those exports, but they must not duplicate the fixture registry
|
||||
or advertise fixture commands in production.
|
||||
Fixtures used for visible stream proof must pace status/tool/content events
|
||||
enough for the browser to paint the intermediate state, including keeping the
|
||||
`/fixture tool-burst` running tool row visible before the matching `tool_end`;
|
||||
|
||||
@@ -16,7 +16,11 @@ vi.mock('@/utils/logger', () => ({
|
||||
}));
|
||||
|
||||
import { AIChatAPI, createAIChatStreamPaintCheckpointPredicate } from '@/api/aiChat';
|
||||
import { maybeRunAIChatDevStreamFixture } from '@/api/aiChatDevStreamFixture';
|
||||
import {
|
||||
AI_CHAT_DEV_STREAM_FIXTURE_ALIAS_NAMES,
|
||||
AI_CHAT_DEV_STREAM_FIXTURE_NAMES,
|
||||
maybeRunAIChatDevStreamFixture,
|
||||
} from '@/api/aiChatDevStreamFixture';
|
||||
import { apiFetch, apiFetchJSON } from '@/utils/apiClient';
|
||||
import { logger } from '@/utils/logger';
|
||||
|
||||
@@ -302,10 +306,7 @@ describe('AIChatAPI', () => {
|
||||
expect(onEvent.mock.calls.map(([event]) => event.type)).toEqual(['session']);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
expect(onEvent.mock.calls.map(([event]) => event.type)).toEqual([
|
||||
'session',
|
||||
'workflow_state',
|
||||
]);
|
||||
expect(onEvent.mock.calls.map(([event]) => event.type)).toEqual(['session', 'workflow_state']);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
expect(onEvent.mock.calls.map(([event]) => event.type)).toEqual([
|
||||
@@ -552,6 +553,13 @@ describe('AIChatAPI', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('exports fixture names and aliases for command discovery', () => {
|
||||
expect(AI_CHAT_DEV_STREAM_FIXTURE_NAMES).toContain('provider-retry');
|
||||
expect(AI_CHAT_DEV_STREAM_FIXTURE_NAMES).toContain('send-hold');
|
||||
expect(AI_CHAT_DEV_STREAM_FIXTURE_NAMES).not.toContain('/fixture provider-retry');
|
||||
expect(AI_CHAT_DEV_STREAM_FIXTURE_ALIAS_NAMES).toEqual(['burst-tool', 'queued-follow-up']);
|
||||
});
|
||||
|
||||
it('runs the pending-tool dev stream fixture without opening a provider request', async () => {
|
||||
const onEvent = vi.fn();
|
||||
|
||||
|
||||
@@ -26,9 +26,14 @@ const AI_CHAT_DEV_STREAM_FIXTURE_ALIASES: Record<
|
||||
'/fixture queued-follow-up': '/fixture queue-drain',
|
||||
};
|
||||
|
||||
const availableFixtureNames = AI_CHAT_DEV_STREAM_FIXTURE_PROMPTS.map((prompt) =>
|
||||
export const AI_CHAT_DEV_STREAM_FIXTURE_NAMES = AI_CHAT_DEV_STREAM_FIXTURE_PROMPTS.map((prompt) =>
|
||||
prompt.replace('/fixture ', ''),
|
||||
);
|
||||
export const AI_CHAT_DEV_STREAM_FIXTURE_ALIAS_NAMES = Object.keys(
|
||||
AI_CHAT_DEV_STREAM_FIXTURE_ALIASES,
|
||||
).map((prompt) => prompt.replace('/fixture ', ''));
|
||||
|
||||
const availableFixtureNames = AI_CHAT_DEV_STREAM_FIXTURE_NAMES;
|
||||
|
||||
const DEFAULT_DEV_FIXTURE_STEP_DELAY_MS = 140;
|
||||
const TEST_FIXTURE_STEP_DELAY_MS = 0;
|
||||
@@ -656,7 +661,7 @@ const buildLongOutputFixtureEvents = (model?: string): AIChatStreamEvent[] => [
|
||||
{
|
||||
type: 'content',
|
||||
data: {
|
||||
text: 'The long-output fixture kept a bounded plain-text tool preview visible while preserving the full output in details.',
|
||||
text: 'The long-output fixture kept the full tool output available in details without flooding the transcript.',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ import ActivityIcon from 'lucide-solid/icons/activity';
|
||||
import ClockIcon from 'lucide-solid/icons/clock';
|
||||
import CopyIcon from 'lucide-solid/icons/copy';
|
||||
import DownloadIcon from 'lucide-solid/icons/download';
|
||||
import FlaskConicalIcon from 'lucide-solid/icons/flask-conical';
|
||||
import GitForkIcon from 'lucide-solid/icons/git-fork';
|
||||
import KeyRoundIcon from 'lucide-solid/icons/key-round';
|
||||
import Minimize2Icon from 'lucide-solid/icons/minimize-2';
|
||||
@@ -47,6 +48,8 @@ export const AssistantSlashCommandIcon = (props: { action: AssistantSlashCommand
|
||||
return <CopyIcon class="h-4 w-4" aria-hidden="true" />;
|
||||
case 'export':
|
||||
return <DownloadIcon class="h-4 w-4" aria-hidden="true" />;
|
||||
case 'fixture':
|
||||
return <FlaskConicalIcon class="h-4 w-4" aria-hidden="true" />;
|
||||
case 'fork':
|
||||
return <GitForkIcon class="h-4 w-4" aria-hidden="true" />;
|
||||
case 'undo':
|
||||
@@ -155,7 +158,9 @@ export function SlashCommandAutocomplete(props: SlashCommandAutocompleteProps) {
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={index() === selectedIndex()}
|
||||
aria-label={`Run /${command.name}: ${command.description}`}
|
||||
aria-label={`${
|
||||
command.insertText ? 'Insert' : 'Run'
|
||||
} /${command.name}: ${command.description}`}
|
||||
class={`flex w-full items-start gap-3 px-3 py-2.5 text-left transition-colors hover:bg-surface-hover ${
|
||||
index() === selectedIndex() ? 'bg-surface-hover' : ''
|
||||
}`}
|
||||
|
||||
@@ -906,7 +906,7 @@ describe('AIChat', () => {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
error:
|
||||
'Unknown Assistant fixture "/fixture typo-check". Available fixtures: devices, assistant-stream, tool-burst, context-group, pending-tool, long-output, provider-retry, stream-idle, queue-hold, queue-drain, compacted-artifact, skipped-tool.',
|
||||
'Unknown Assistant fixture "/fixture typo-check". Available fixtures: devices, assistant-stream, send-hold, tool-burst, workflow-burst, context-group, status-boundary, pending-tool, long-output, provider-retry, stream-idle, queue-hold, queue-drain, compacted-artifact, skipped-tool.',
|
||||
timestamp: new Date('2026-06-05T10:00:00Z'),
|
||||
model: 'openrouter:qwen/qwen3.7-plus',
|
||||
streamEvents: [
|
||||
@@ -1334,9 +1334,7 @@ describe('AIChat', () => {
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(mockChat.setModel).not.toHaveBeenCalledWith(
|
||||
'openrouter:deepseek/deepseek-v4-pro',
|
||||
);
|
||||
expect(mockChat.setModel).not.toHaveBeenCalledWith('openrouter:deepseek/deepseek-v4-pro');
|
||||
expect(
|
||||
screen.queryByRole('status', { name: 'Assistant provider readiness route adopted' }),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -1784,6 +1782,38 @@ describe('AIChat', () => {
|
||||
expect(mockChat.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('inserts the local fixture command from slash suggestions without sending yet', async () => {
|
||||
renderChat();
|
||||
const textarea = screen.getByPlaceholderText(
|
||||
'Ask about your infrastructure...',
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.input(textarea, { target: { value: '/fi' } });
|
||||
|
||||
expect(screen.getByRole('option', { name: /Insert \/fixture/ })).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||
|
||||
await waitFor(() => expect(textarea.value).toBe('/fixture '));
|
||||
expect(mockChat.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs a local fixture slash command through the normal chat send path', async () => {
|
||||
renderChat();
|
||||
const textarea = screen.getByPlaceholderText('Ask about your infrastructure...');
|
||||
|
||||
fireEvent.input(textarea, { target: { value: '/fixture provider-retry' } });
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChat.sendMessage).toHaveBeenCalledWith(
|
||||
'/fixture provider-retry',
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('omits unavailable session commands from slash suggestions', () => {
|
||||
renderChat();
|
||||
const textarea = screen.getByPlaceholderText('Ask about your infrastructure...');
|
||||
@@ -5502,16 +5532,12 @@ describe('AIChat', () => {
|
||||
expect(status).not.toHaveTextContent('Reading current Pulse inventory.');
|
||||
|
||||
vi.advanceTimersByTime(1_000);
|
||||
await waitFor(() =>
|
||||
expect(status).toHaveTextContent('Reading current Pulse inventory.'),
|
||||
);
|
||||
await waitFor(() => expect(status).toHaveTextContent('Reading current Pulse inventory.'));
|
||||
expect(status).not.toHaveTextContent('Preparing Pulse context.');
|
||||
expect(status).not.toHaveTextContent('OpenRouter is starting the response.');
|
||||
|
||||
vi.advanceTimersByTime(1_000);
|
||||
await waitFor(() =>
|
||||
expect(status).toHaveTextContent('OpenRouter is starting the response.'),
|
||||
);
|
||||
await waitFor(() => expect(status).toHaveTextContent('OpenRouter is starting the response.'));
|
||||
expect(status).not.toHaveTextContent('Preparing Pulse context.');
|
||||
expect(status).not.toHaveTextContent('Reading current Pulse inventory.');
|
||||
});
|
||||
@@ -5579,9 +5605,7 @@ describe('AIChat', () => {
|
||||
|
||||
vi.advanceTimersByTime(1_000);
|
||||
await waitFor(() =>
|
||||
expect(status).toHaveTextContent(
|
||||
'Reading current Pulse inventory. · 1 follow-up queued',
|
||||
),
|
||||
expect(status).toHaveTextContent('Reading current Pulse inventory. · 1 follow-up queued'),
|
||||
);
|
||||
expect(status).not.toHaveTextContent('Preparing Pulse context.');
|
||||
expect(status).not.toHaveTextContent('OpenRouter is starting the response.');
|
||||
|
||||
@@ -32,6 +32,17 @@ describe('AssistantCommandHelpDialog', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('finds local stream fixtures from command help search', () => {
|
||||
render(() => <AssistantCommandHelpDialog onClose={vi.fn()} onRunCommand={vi.fn()} />);
|
||||
|
||||
fireEvent.input(screen.getByLabelText('Search Assistant commands'), {
|
||||
target: { value: 'provider-retry' },
|
||||
});
|
||||
|
||||
expect(screen.getByRole('option', { name: /\/fixture/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: /\/fixture/ })).toHaveTextContent('/fixture');
|
||||
});
|
||||
|
||||
it('moves command selection with arrow keys before running', () => {
|
||||
const onRunCommand = vi.fn();
|
||||
render(() => <AssistantCommandHelpDialog onClose={vi.fn()} onRunCommand={onRunCommand} />);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render, screen } from '@solidjs/testing-library';
|
||||
import { ASSISTANT_SLASH_COMMANDS } from '../assistantSlashCommands';
|
||||
import { filterAssistantSlashCommands } from '../assistantSlashCommands';
|
||||
import { SlashCommandAutocomplete } from '../SlashCommandAutocomplete';
|
||||
|
||||
afterEach(cleanup);
|
||||
@@ -69,6 +69,7 @@ describe('SlashCommandAutocomplete', () => {
|
||||
|
||||
it('wraps keyboard selection from the first command to the last command', () => {
|
||||
const onSelect = vi.fn();
|
||||
const lastCommand = filterAssistantSlashCommands('').at(-1);
|
||||
render(() => (
|
||||
<SlashCommandAutocomplete
|
||||
query=""
|
||||
@@ -96,8 +97,8 @@ describe('SlashCommandAutocomplete', () => {
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'redo',
|
||||
name: 'redo',
|
||||
action: lastCommand?.action,
|
||||
name: lastCommand?.name,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -113,14 +114,31 @@ describe('SlashCommandAutocomplete', () => {
|
||||
/>
|
||||
));
|
||||
|
||||
const activeCommands = filterAssistantSlashCommands('');
|
||||
const options = screen.getAllByRole('option');
|
||||
expect(options).toHaveLength(ASSISTANT_SLASH_COMMANDS.length);
|
||||
expect(options).toHaveLength(activeCommands.length);
|
||||
expect(options.map((option) => option.getAttribute('aria-label'))).toContain(
|
||||
'Run /compact: Summarize older turns and keep this session moving',
|
||||
);
|
||||
expect(container.querySelectorAll('[role="option"] svg')).toHaveLength(
|
||||
ASSISTANT_SLASH_COMMANDS.length,
|
||||
);
|
||||
expect(container.querySelectorAll('[role="option"] svg')).toHaveLength(activeCommands.length);
|
||||
});
|
||||
|
||||
it('surfaces dev stream fixtures through slash command search', () => {
|
||||
render(() => (
|
||||
<SlashCommandAutocomplete
|
||||
query="provider-retry"
|
||||
visible
|
||||
position={{ top: 58, left: 0 }}
|
||||
onClose={vi.fn()}
|
||||
onSelect={vi.fn()}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(
|
||||
screen.getByRole('option', {
|
||||
name: /Insert \/fixture: Run a local stream fixture by name/,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an empty state when visible command search has no enabled options', () => {
|
||||
|
||||
@@ -32,6 +32,8 @@ describe('assistantSlashCommands', () => {
|
||||
expect(parseAssistantSlashCommand('/fork')).toBe('fork');
|
||||
expect(parseAssistantSlashCommand('/undo')).toBe('undo');
|
||||
expect(parseAssistantSlashCommand('/redo')).toBe('redo');
|
||||
expect(parseAssistantSlashCommand('/fixture')).toBe('fixture');
|
||||
expect(parseAssistantSlashCommand('/fixtures')).toBe('fixture');
|
||||
});
|
||||
|
||||
it('normalizes casing and surrounding whitespace', () => {
|
||||
@@ -61,7 +63,19 @@ describe('assistantSlashCommands', () => {
|
||||
expect(parseAssistantSlashCommand('/model openrouter:qwen/qwen3.7-plus')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not parse arguments for non-model commands', () => {
|
||||
it('parses dev fixture commands with stream fixture arguments', () => {
|
||||
expect(parseAssistantSlashCommandInput('/fixture provider-retry')).toEqual({
|
||||
action: 'fixture',
|
||||
args: 'provider-retry',
|
||||
});
|
||||
expect(parseAssistantSlashCommandInput('/fixtures queued-follow-up')).toEqual({
|
||||
action: 'fixture',
|
||||
args: 'queued-follow-up',
|
||||
});
|
||||
expect(parseAssistantSlashCommand('/fixture provider-retry')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not parse arguments for commands that do not accept them', () => {
|
||||
expect(parseAssistantSlashCommandInput('/copy this sentence')).toBeNull();
|
||||
expect(parseAssistantSlashCommandInput('/status now')).toBeNull();
|
||||
});
|
||||
@@ -80,6 +94,7 @@ describe('assistantSlashCommands', () => {
|
||||
'fork',
|
||||
'undo',
|
||||
'redo',
|
||||
'fixture',
|
||||
]);
|
||||
expect(filterAssistantSlashCommands('resume').map((command) => command.name)).toEqual([
|
||||
'sessions',
|
||||
@@ -90,6 +105,7 @@ describe('assistantSlashCommands', () => {
|
||||
expect(filterAssistantSlashCommands('provider').map((command) => command.name)).toEqual([
|
||||
'providers',
|
||||
'models',
|
||||
'fixture',
|
||||
]);
|
||||
expect(filterAssistantSlashCommands('connect').map((command) => command.name)).toEqual([
|
||||
'providers',
|
||||
@@ -100,6 +116,27 @@ describe('assistantSlashCommands', () => {
|
||||
expect(filterAssistantSlashCommands('commands').map((command) => command.name)).toEqual([
|
||||
'help',
|
||||
]);
|
||||
expect(filterAssistantSlashCommands('provider-retry').map((command) => command.name)).toEqual([
|
||||
'fixture',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps dev fixture commands out of production command surfaces', () => {
|
||||
expect(
|
||||
filterAssistantSlashCommands('', undefined, {
|
||||
includeDevCommands: false,
|
||||
}).map((command) => command.name),
|
||||
).not.toContain('fixture');
|
||||
expect(
|
||||
filterAssistantSlashCommands('provider-retry', undefined, {
|
||||
includeDevCommands: false,
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
parseAssistantSlashCommandInput('/fixture provider-retry', {
|
||||
includeDevCommands: false,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('filters disabled commands from the prompt slash list by default', () => {
|
||||
@@ -154,5 +191,7 @@ describe('assistantSlashCommands', () => {
|
||||
]);
|
||||
const status = filterAssistantSlashCommands('status')[0];
|
||||
expect(getAssistantSlashCommandTokens(status)).toEqual(['status', 'runtime', 'health']);
|
||||
const fixture = filterAssistantSlashCommands('fixture')[0];
|
||||
expect(getAssistantSlashCommandTokens(fixture)).toEqual(['fixture', 'fixtures']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import {
|
||||
AI_CHAT_DEV_STREAM_FIXTURE_ALIAS_NAMES,
|
||||
AI_CHAT_DEV_STREAM_FIXTURE_NAMES,
|
||||
} from '@/api/aiChatDevStreamFixture';
|
||||
|
||||
export type AssistantSlashCommandAction =
|
||||
| 'compact'
|
||||
| 'copy'
|
||||
| 'export'
|
||||
| 'fixture'
|
||||
| 'fork'
|
||||
| 'help'
|
||||
| 'models'
|
||||
@@ -15,9 +21,12 @@ export type AssistantSlashCommandAction =
|
||||
export interface AssistantSlashCommand {
|
||||
action: AssistantSlashCommandAction;
|
||||
aliases?: string[];
|
||||
acceptsArgs?: boolean;
|
||||
disabled?: boolean;
|
||||
disabledReason?: string;
|
||||
description: string;
|
||||
insertText?: string;
|
||||
keywords?: string[];
|
||||
name: string;
|
||||
}
|
||||
|
||||
@@ -27,14 +36,44 @@ export type AssistantSlashCommandAvailability = Partial<
|
||||
|
||||
export interface AssistantSlashCommandFilterOptions {
|
||||
availability?: AssistantSlashCommandAvailability;
|
||||
includeDevCommands?: boolean;
|
||||
includeDisabled?: boolean;
|
||||
}
|
||||
|
||||
export interface AssistantSlashCommandParseOptions {
|
||||
includeDevCommands?: boolean;
|
||||
}
|
||||
|
||||
export interface ParsedAssistantSlashCommand {
|
||||
action: AssistantSlashCommandAction;
|
||||
args: string;
|
||||
}
|
||||
|
||||
const isAssistantDevCommandSurfaceEnabled = () =>
|
||||
import.meta.env.DEV || import.meta.env.MODE === 'test';
|
||||
|
||||
const includeDevCommandsForOptions = (includeDevCommands?: boolean) =>
|
||||
includeDevCommands ?? isAssistantDevCommandSurfaceEnabled();
|
||||
|
||||
const DEV_ASSISTANT_SLASH_COMMANDS: AssistantSlashCommand[] = [
|
||||
{
|
||||
name: 'fixture',
|
||||
aliases: ['fixtures'],
|
||||
acceptsArgs: true,
|
||||
action: 'fixture',
|
||||
description: 'Run a local stream fixture by name (/fixture provider-retry)',
|
||||
insertText: '/fixture ',
|
||||
keywords: [
|
||||
'dev',
|
||||
'local',
|
||||
'stream',
|
||||
'test',
|
||||
...AI_CHAT_DEV_STREAM_FIXTURE_NAMES,
|
||||
...AI_CHAT_DEV_STREAM_FIXTURE_ALIAS_NAMES,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const ASSISTANT_SLASH_COMMANDS: AssistantSlashCommand[] = [
|
||||
{
|
||||
name: 'help',
|
||||
@@ -63,6 +102,7 @@ export const ASSISTANT_SLASH_COMMANDS: AssistantSlashCommand[] = [
|
||||
{
|
||||
name: 'models',
|
||||
aliases: ['model', 'mo'],
|
||||
acceptsArgs: true,
|
||||
action: 'models',
|
||||
description: 'Open model search or set a route (/model qwen or /model provider:model-id)',
|
||||
},
|
||||
@@ -105,15 +145,28 @@ export const ASSISTANT_SLASH_COMMANDS: AssistantSlashCommand[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const commandByToken = new Map<string, AssistantSlashCommandAction>(
|
||||
ASSISTANT_SLASH_COMMANDS.flatMap((command) => [
|
||||
[command.name, command.action],
|
||||
const assistantSlashCommandsForOptions = (includeDevCommands?: boolean): AssistantSlashCommand[] =>
|
||||
includeDevCommandsForOptions(includeDevCommands)
|
||||
? [...ASSISTANT_SLASH_COMMANDS, ...DEV_ASSISTANT_SLASH_COMMANDS]
|
||||
: ASSISTANT_SLASH_COMMANDS;
|
||||
|
||||
const commandEntriesForOptions = (
|
||||
includeDevCommands?: boolean,
|
||||
): Array<readonly [string, AssistantSlashCommandAction]> =>
|
||||
assistantSlashCommandsForOptions(includeDevCommands).flatMap((command) => [
|
||||
[command.name, command.action] as const,
|
||||
...(command.aliases || []).map((alias) => [alias, command.action] as const),
|
||||
]),
|
||||
);
|
||||
]);
|
||||
|
||||
const commandByToken = (includeDevCommands?: boolean) =>
|
||||
new Map<string, AssistantSlashCommandAction>(commandEntriesForOptions(includeDevCommands));
|
||||
|
||||
const commandForAction = (action: AssistantSlashCommandAction, includeDevCommands?: boolean) =>
|
||||
assistantSlashCommandsForOptions(includeDevCommands).find((command) => command.action === action);
|
||||
|
||||
export const parseAssistantSlashCommandInput = (
|
||||
input: string,
|
||||
options: AssistantSlashCommandParseOptions = {},
|
||||
): ParsedAssistantSlashCommand | null => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed.startsWith('/')) return null;
|
||||
@@ -124,17 +177,21 @@ export const parseAssistantSlashCommandInput = (
|
||||
|
||||
const token = match[1].toLowerCase();
|
||||
if (!token) return null;
|
||||
const action = commandByToken.get(token);
|
||||
const action = commandByToken(options.includeDevCommands).get(token);
|
||||
if (!action) return null;
|
||||
|
||||
const args = (match[2] || '').trim();
|
||||
if (args && action !== 'models') return null;
|
||||
const command = commandForAction(action, options.includeDevCommands);
|
||||
if (args && !command?.acceptsArgs) return null;
|
||||
|
||||
return { action, args };
|
||||
};
|
||||
|
||||
export const parseAssistantSlashCommand = (input: string): AssistantSlashCommandAction | null => {
|
||||
const parsed = parseAssistantSlashCommandInput(input);
|
||||
export const parseAssistantSlashCommand = (
|
||||
input: string,
|
||||
options: AssistantSlashCommandParseOptions = {},
|
||||
): AssistantSlashCommandAction | null => {
|
||||
const parsed = parseAssistantSlashCommandInput(input, options);
|
||||
if (!parsed || parsed.args) return null;
|
||||
return parsed.action;
|
||||
};
|
||||
@@ -156,11 +213,21 @@ const commandWithAvailability = (
|
||||
};
|
||||
};
|
||||
|
||||
const keywordMatchesQuery = (keyword: string, query: string) => {
|
||||
const normalizedKeyword = keyword.toLowerCase();
|
||||
return (
|
||||
normalizedKeyword === query ||
|
||||
normalizedKeyword.startsWith(`${query}-`) ||
|
||||
normalizedKeyword.startsWith(`${query}_`)
|
||||
);
|
||||
};
|
||||
|
||||
const commandMatchesQuery = (command: AssistantSlashCommand, query: string) => {
|
||||
if (!query) return true;
|
||||
if (command.name.toLowerCase().includes(query)) return true;
|
||||
if (command.description.toLowerCase().includes(query)) return true;
|
||||
return commandAliases(command).some((alias) => alias.toLowerCase().includes(query));
|
||||
if (commandAliases(command).some((alias) => alias.toLowerCase().includes(query))) return true;
|
||||
return (command.keywords || []).some((keyword) => keywordMatchesQuery(keyword, query));
|
||||
};
|
||||
|
||||
const commandMatchScore = (command: AssistantSlashCommand, query: string) => {
|
||||
@@ -179,13 +246,13 @@ export const getAssistantSlashCommandTokens = (command: AssistantSlashCommand):
|
||||
|
||||
export const filterAssistantSlashCommands = (
|
||||
query: string,
|
||||
limit = ASSISTANT_SLASH_COMMANDS.length,
|
||||
limit?: number,
|
||||
options: AssistantSlashCommandFilterOptions = {},
|
||||
): AssistantSlashCommand[] => {
|
||||
const normalizedQuery = normalizeSlashQuery(query);
|
||||
return ASSISTANT_SLASH_COMMANDS.map((command) =>
|
||||
commandWithAvailability(command, options.availability),
|
||||
)
|
||||
const commands = assistantSlashCommandsForOptions(options.includeDevCommands);
|
||||
return commands
|
||||
.map((command) => commandWithAvailability(command, options.availability))
|
||||
.filter((command) => options.includeDisabled || !command.disabled)
|
||||
.filter((command) => commandMatchesQuery(command, normalizedQuery))
|
||||
.map((command, index) => ({ command, index }))
|
||||
@@ -195,6 +262,6 @@ export const filterAssistantSlashCommands = (
|
||||
commandMatchScore(right.command, normalizedQuery);
|
||||
return scoreDelta || left.index - right.index;
|
||||
})
|
||||
.slice(0, limit)
|
||||
.slice(0, limit ?? commands.length)
|
||||
.map(({ command }) => command);
|
||||
};
|
||||
|
||||
@@ -2863,6 +2863,43 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
setShowCommandHelp(true);
|
||||
};
|
||||
|
||||
const insertSlashCommandDraft = (command: AssistantSlashCommand) => {
|
||||
const nextInput = command.insertText || `/${command.name}`;
|
||||
setShowCommandHelp(false);
|
||||
setSlashCommandActive(false);
|
||||
setSlashCommandQuery('');
|
||||
setInput(nextInput);
|
||||
queueMicrotask(() => {
|
||||
if (textareaRef) {
|
||||
textareaRef.selectionStart = nextInput.length;
|
||||
textareaRef.selectionEnd = nextInput.length;
|
||||
}
|
||||
resizeTextarea();
|
||||
focusComposer();
|
||||
});
|
||||
};
|
||||
|
||||
const runAssistantFixtureSlashCommand = (args: string) => {
|
||||
const fixturePrompt = args.trim() ? `/fixture ${args.trim()}` : '/fixture';
|
||||
const submittedMentions: MentionResource[] = [];
|
||||
const sendPromise = chat.sendMessage(fixturePrompt, undefined, undefined);
|
||||
addPromptHistoryEntry(fixturePrompt, submittedMentions);
|
||||
resetPromptHistoryNavigation();
|
||||
sendPromise
|
||||
.then((ok) => {
|
||||
if (!ok) {
|
||||
restoreFailedSubmitDraft(fixturePrompt, submittedMentions);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.warn('[AIChat] Failed to run Assistant fixture command:', error);
|
||||
restoreFailedSubmitDraft(fixturePrompt, submittedMentions);
|
||||
});
|
||||
clearLocalComposerCommand();
|
||||
setShowCommandHelp(false);
|
||||
return true;
|
||||
};
|
||||
|
||||
const executeSlashCommand = (command: AssistantSlashCommandAction, args = '') => {
|
||||
const commandArgs = args.trim();
|
||||
const disabledReason = disabledAssistantCommandReason(command);
|
||||
@@ -2882,6 +2919,10 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
return consumed;
|
||||
}
|
||||
|
||||
if (command === 'fixture') {
|
||||
return runAssistantFixtureSlashCommand(commandArgs);
|
||||
}
|
||||
|
||||
clearLocalComposerCommand();
|
||||
|
||||
switch (command) {
|
||||
@@ -3129,12 +3170,20 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
const handleSlashCommandSelect = (command: AssistantSlashCommand) => {
|
||||
setSlashCommandActive(false);
|
||||
setSlashCommandQuery('');
|
||||
if (command.insertText) {
|
||||
insertSlashCommandDraft(command);
|
||||
return;
|
||||
}
|
||||
setInput(`/${command.name}`);
|
||||
executeSlashCommand(command.action);
|
||||
};
|
||||
|
||||
const handleCommandHelpRun = (command: AssistantSlashCommand) => {
|
||||
setShowCommandHelp(false);
|
||||
if (command.insertText) {
|
||||
queueMicrotask(() => insertSlashCommandDraft(command));
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => executeSlashCommand(command.action));
|
||||
};
|
||||
|
||||
|
||||
@@ -2860,7 +2860,7 @@ class SubsystemLookupTest(unittest.TestCase):
|
||||
{
|
||||
"heading": "## Shared Boundaries",
|
||||
"path": "internal/api/access_control_handlers.go",
|
||||
"line": 432,
|
||||
"line": 436,
|
||||
"heading_line": 117,
|
||||
}
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user