Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';

import {
getSlashCommandCandidate,
getSlashCommandSuggestions,
prepareChatComposerSubmission,
} from '@/components/agents/chat-composer-slash-commands';

const commands = [
{ name: 'compact', description: 'Compact context', hints: [] },
{ name: 'review', description: 'Review changes', hints: ['$ARGUMENTS'] },
{ name: 'rename', description: 'Rename a symbol', hints: ['$ARGUMENTS'] },
];

describe('getSlashCommandCandidate', () => {
it('keeps input that can still match a command name', () => {
expect(getSlashCommandCandidate('/')).toBe('/');
expect(getSlashCommandCandidate('/re')).toBe('/re');
});

it('collapses prose and argument text to null', () => {
expect(getSlashCommandCandidate('hello')).toBeNull();
expect(getSlashCommandCandidate('/review main')).toBeNull();
expect(getSlashCommandCandidate('')).toBeNull();
});
});

describe('getSlashCommandSuggestions', () => {
it('filters the current catalog by the command-name prefix', () => {
expect(getSlashCommandSuggestions('/re', commands)).toEqual([commands[1], commands[2]]);
});

it('closes after command arguments begin or when input is not slash-prefixed', () => {
expect(getSlashCommandSuggestions('/review main', commands)).toEqual([]);
expect(getSlashCommandSuggestions('review', commands)).toEqual([]);
});
});

describe('prepareChatComposerSubmission', () => {
it('parses a recognized slash command and preserves its argument text', () => {
expect(prepareChatComposerSubmission(' /review main branch ', commands, false)).toEqual({
type: 'command',
command: 'review',
arguments: 'main branch',
});
});

it('keeps unknown slash-prefixed input as a prompt', () => {
expect(prepareChatComposerSubmission(' /unknown keep this ', commands, true)).toEqual({
type: 'prompt',
prompt: '/unknown keep this',
});
});

it('rejects attachments only for recognized commands', () => {
expect(prepareChatComposerSubmission('/compact', commands, true)).toEqual({
type: 'attachment-error',
});
expect(prepareChatComposerSubmission('/not-a-command', commands, true)).toEqual({
type: 'prompt',
prompt: '/not-a-command',
});
});
});
54 changes: 54 additions & 0 deletions apps/mobile/src/components/agents/chat-composer-slash-commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { type SlashCommandInfo } from 'cloud-agent-sdk';

type ChatComposerSubmission =
| { type: 'prompt'; prompt: string }
| { type: 'command'; command: string; arguments: string }
| { type: 'attachment-error' };

const SLASH_COMMAND_PATTERN = /^\/([\w.-]+)(?:\s+([\s\S]*))?$/;
const SLASH_CANDIDATE_PATTERN = /^\/[\w.-]*$/;

/**
* Returns the input when it can still match a command name, `null` otherwise.
* Keeping non-candidates collapsed to `null` lets the composer skip
* re-rendering on every keystroke of ordinary prose.
*/
export function getSlashCommandCandidate(input: string): string | null {
return SLASH_CANDIDATE_PATTERN.test(input) ? input : null;
}

export function getSlashCommandSuggestions(
input: string,
commands: SlashCommandInfo[]
): SlashCommandInfo[] {
const match = /^\/([\w.-]*)$/.exec(input);
if (!match) {
return [];
}

const prefix = match[1] ?? '';
return commands.filter(command => command.name.startsWith(prefix));
}

export function prepareChatComposerSubmission(
input: string,
commands: SlashCommandInfo[],
hasAttachments: boolean
): ChatComposerSubmission {
const trimmed = input.trim();
const match = SLASH_COMMAND_PATTERN.exec(trimmed);
const commandName = match?.[1];
const recognized = commandName ? commands.some(command => command.name === commandName) : false;

if (!recognized || !commandName) {
return { type: 'prompt', prompt: trimmed };
}
if (hasAttachments) {
return { type: 'attachment-error' };
}
return {
type: 'command',
command: commandName,
arguments: match[2]?.trim() ?? '',
};
}
53 changes: 50 additions & 3 deletions apps/mobile/src/components/agents/chat-composer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as Haptics from 'expo-haptics';
import { useActionSheet } from '@expo/react-native-action-sheet';
import { type SlashCommandInfo } from 'cloud-agent-sdk';
import { ArrowUp, Paperclip, Square } from 'lucide-react-native';
import { useCallback, useRef, useState } from 'react';
import {
Expand All @@ -14,9 +15,15 @@ import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { toast } from 'sonner-native';

import { AttachmentPreviewStrip } from '@/components/agents/attachment-preview-strip';
import {
getSlashCommandCandidate,
getSlashCommandSuggestions,
prepareChatComposerSubmission,
} from '@/components/agents/chat-composer-slash-commands';
import { ChatToolbar } from '@/components/agents/chat-toolbar';
import { type AgentMode } from '@/components/agents/mode-selector';
import { pickAgentAttachments } from '@/components/agents/attachment-picker';
import { SlashCommandSuggestions } from '@/components/agents/slash-command-suggestions';
import { useTextHeight } from '@/components/agents/use-text-height';
import { BlurBar } from '@/components/ui/blur-bar';
import { AGENT_ATTACHMENT_MAX_FILES } from '@/lib/agent-attachments/constants';
Expand All @@ -36,9 +43,11 @@ const TEXT_INPUT_MAX_HEIGHT =
TEXT_INPUT_LINE_HEIGHT * TEXT_INPUT_MAX_LINES + TEXT_INPUT_VERTICAL_PADDING;

const PAPERCLIP_HIT_SLOP = { top: 8, bottom: 8, left: 8, right: 8 } as const;
const EMPTY_COMMANDS: SlashCommandInfo[] = [];

type ChatComposerProps = {
onSend: (text: string, attachments?: AgentAttachmentWire) => void | Promise<void>;
onSendCommand: (command: string, argumentsText: string) => void | Promise<void>;
onStop?: () => void | Promise<void>;
disabled?: boolean;
isStreaming?: boolean;
Expand All @@ -52,10 +61,12 @@ type ChatComposerProps = {
organizationId?: string;
/** Only Cloud Agent sessions can receive attachments. */
attachmentsEnabled?: boolean;
commands?: SlashCommandInfo[];
};

export function ChatComposer({
onSend,
onSendCommand,
onStop,
disabled = false,
isStreaming = false,
Expand All @@ -68,12 +79,14 @@ export function ChatComposer({
onModelSelect,
organizationId,
attachmentsEnabled = true,
commands = EMPTY_COMMANDS,
}: Readonly<ChatComposerProps>) {
const colors = useThemeColors();
const { showActionSheetWithOptions } = useActionSheet();
const textRef = useRef('');
const inputRef = useRef<TextInput>(null);
const [hasText, setHasText] = useState(false);
const [slashCommandInput, setSlashCommandInput] = useState<string | null>(null);
const [inputWidth, setInputWidth] = useState(0);
const [isFocused, setIsFocused] = useState(false);
const upload = useAgentAttachmentUpload({ organizationId });
Expand All @@ -91,11 +104,14 @@ export function ChatComposer({
const canSend = hasText && !disabled && !isStreaming;
const showToolbar = isFocused || hasText || upload.attachments.length > 0;
const toolbarDisabled = disabled || isStreaming;
const slashCommandSuggestions =
slashCommandInput === null ? [] : getSlashCommandSuggestions(slashCommandInput, commands);

function handleChangeText(value: string) {
textRef.current = value;
measure.setText(value);
setHasText(value.trim().length > 0);
setSlashCommandInput(getSlashCommandCandidate(value));
}

function handleSend() {
Expand All @@ -107,22 +123,44 @@ export function ChatComposer({
toast.error('Wait for attachments to finish uploading.');
return;
}
if (trimmed.startsWith('/') && upload.attachments.length > 0) {
const submission = prepareChatComposerSubmission(
trimmed,
commands,
upload.attachments.length > 0
);
if (submission.type === 'attachment-error') {
toast.error('Attachments cannot be sent with slash commands.');
return;
}

void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const payload = upload.toWirePayload();
void onSend(trimmed, payload);
if (submission.type === 'command') {
void onSendCommand(submission.command, submission.arguments);
} else {
void onSend(submission.prompt, upload.toWirePayload());
}
textRef.current = '';
setHasText(false);
setSlashCommandInput(null);
measure.reset();
inputRef.current?.clear();
upload.reset();
Keyboard.dismiss();
}

function handleSelectSlashCommand(command: SlashCommandInfo) {
const value = `/${command.name} `;
textRef.current = value;
measure.setText(value);
setHasText(true);
setSlashCommandInput(null);
inputRef.current?.setNativeProps({
text: value,
selection: { start: value.length, end: value.length },
});
inputRef.current?.focus();
}

function handleStop() {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
void onStop?.();
Expand Down Expand Up @@ -173,6 +211,15 @@ export function ChatComposer({
<AttachmentPreviewStrip attachments={upload.attachments} onRemove={removeAttachment} />
) : null}

{slashCommandSuggestions.length > 0 ? (
<Animated.View entering={FadeIn.duration(150)} exiting={FadeOut.duration(100)}>
<SlashCommandSuggestions
commands={slashCommandSuggestions}
onSelect={handleSelectSlashCommand}
/>
</Animated.View>
) : null}

<View className="flex-row items-center p-2.5 px-3">
{attachmentsEnabled ? (
<Pressable
Expand Down
18 changes: 18 additions & 0 deletions apps/mobile/src/components/agents/session-detail-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export function SessionDetailContent({
const supportsAttachments = useAtomValue(manager.atoms.supportsAttachments);
const activeQuestion = useAtomValue(manager.atoms.activeQuestion);
const activePermission = useAtomValue(manager.atoms.activePermission);
const availableCommands = useAtomValue(manager.atoms.availableCommands);
const totalCost = useAtomValue(manager.atoms.totalCost);
const getChildMessages = useAtomValue(manager.atoms.childMessages);
const pendingMessages = useAtomValue(manager.atoms.pendingMessages);
Expand Down Expand Up @@ -343,6 +344,21 @@ export function SessionDetailContent({
]
);

const handleSendCommand = useCallback(
async (command: string, argumentsText: string) => {
// send() reports failures via its return value instead of throwing.
const accepted = await manager.send({
payload: { type: 'command', command, arguments: argumentsText },
});
if (!accepted) {
toast.error('Failed to send command. Please try again.');
return;
}
captureEvent(MESSAGE_SENT_EVENT, { surface: analyticsSurface });
},
[analyticsSurface, manager]
);

return (
<View className="flex-1 bg-background">
<ScreenHeader
Expand Down Expand Up @@ -415,6 +431,7 @@ export function SessionDetailContent({
>
<ChatComposer
onSend={handleSend}
onSendCommand={handleSendCommand}
onStop={handleStop}
disabled={isComposerDisabled}
isStreaming={isStreaming}
Expand All @@ -427,6 +444,7 @@ export function SessionDetailContent({
onModelSelect={handleModelSelect}
organizationId={organizationId}
attachmentsEnabled={supportsAttachments}
commands={availableCommands}
/>
</ModelPickerSelectionScopeProvider>
</>
Expand Down
44 changes: 44 additions & 0 deletions apps/mobile/src/components/agents/slash-command-suggestions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { type SlashCommandInfo } from 'cloud-agent-sdk';
import { Pressable, ScrollView } from 'react-native';

import { Text } from '@/components/ui/text';

type SlashCommandSuggestionsProps = {
commands: SlashCommandInfo[];
onSelect: (command: SlashCommandInfo) => void;
};

export function SlashCommandSuggestions({
commands,
onSelect,
}: Readonly<SlashCommandSuggestionsProps>) {
if (commands.length === 0) {
return null;
}

return (
<ScrollView
className="max-h-48 border-b border-border px-3 py-1"
keyboardShouldPersistTaps="handled"
>
{commands.map(command => (
<Pressable
key={command.name}
onPress={() => {
onSelect(command);
}}
className="rounded-md px-3 py-2 active:bg-neutral-200 active:opacity-70 dark:active:bg-neutral-700"
accessibilityRole="button"
accessibilityLabel={`Use /${command.name}`}
>
<Text className="text-sm font-semibold">/{command.name}</Text>
{command.description ? (
<Text className="mt-0.5 text-xs text-muted-foreground" numberOfLines={1}>
{command.description}
</Text>
) : null}
</Pressable>
))}
</ScrollView>
);
}
26 changes: 26 additions & 0 deletions apps/web/src/hooks/slash-command-selection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { SlashCommandInfo } from '@/lib/cloud-agent-sdk';
import type { ActiveSessionType } from '@/lib/cloud-agent-sdk/session-manager';
import type { SlashCommand } from '@/lib/cloud-agent/slash-commands';
import { commandsOrDefault } from '@cloud-agent-shared';

export function selectSlashCommands(
sessionType: ActiveSessionType | null,
commands: SlashCommandInfo[]
): SlashCommand[] {
const selectedCommands =
sessionType === 'cloud-agent'
? commandsOrDefault(commands)
: sessionType === 'remote'
? commands
: [];
return selectedCommands.map(toSlashCommand);
}

function toSlashCommand(info: SlashCommandInfo): SlashCommand {
return {
trigger: info.name,
label: info.name,
description: info.description ?? '',
expansion: '',
};
}
Loading