diff --git a/apps/mobile/src/components/agents/mobile-session-manager.ts b/apps/mobile/src/components/agents/mobile-session-manager.ts index 42e05630d0..fa0796cf7a 100644 --- a/apps/mobile/src/components/agents/mobile-session-manager.ts +++ b/apps/mobile/src/components/agents/mobile-session-manager.ts @@ -23,6 +23,7 @@ import { WEB_BASE_URL, } from '@/lib/config'; import { AUTH_TOKEN_KEY } from '@/lib/storage-keys'; +import { type SendMessagePayload } from '@/lib/cloud-agent-next/types'; type CreateMobileAgentSessionManagerOptions = { store: JotaiStore; @@ -114,28 +115,41 @@ export function createMobileAgentSessionManager({ return result.token; }, api: { - send: async payload => { + send: async input => { await withCloudAgentDiagnostics('send', organizationId, async () => { - if (!payload.model) { - throw new Error('Model is required'); - } - const input = { - cloudAgentSessionId: payload.sessionId as string, - prompt: payload.prompt, - mode: normalizeAgentMode(payload.mode), - model: payload.model, - variant: payload.variant, + const payload: SendMessagePayload = (() => { + if (input.payload.type === 'prompt') { + if (!input.payload.model) { + throw new Error('Model is required'); + } + return { + type: 'prompt' as const, + prompt: input.payload.prompt, + mode: normalizeAgentMode(input.payload.mode), + model: input.payload.model, + variant: input.payload.variant, + }; + } + return { + type: 'command' as const, + command: input.payload.command, + arguments: input.payload.arguments, + }; + })(); + const baseInput = { + cloudAgentSessionId: input.sessionId as string, + payload, autoCommit: true, - messageId: payload.messageId, + messageId: input.messageId, }; if (organizationId) { await trpcClient.organizations.cloudAgentNext.sendMessage.mutate( - { ...input, organizationId }, + { ...baseInput, organizationId }, skipBatchOptions ); return; } - await trpcClient.cloudAgentNext.sendMessage.mutate(input, skipBatchOptions); + await trpcClient.cloudAgentNext.sendMessage.mutate(baseInput, skipBatchOptions); }); }, interrupt: async payload => { diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 1435dc6d26..d29e18aca8 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -190,10 +190,13 @@ export function SessionDetailContent({ sessionId }: Readonly; }, diff --git a/apps/storybook/stories/cloud-agent/ProfileVarEditor.stories.tsx b/apps/storybook/stories/cloud-agent/ProfileVarEditor.stories.tsx index 5cc9e303c3..3c7ad6589a 100644 --- a/apps/storybook/stories/cloud-agent/ProfileVarEditor.stories.tsx +++ b/apps/storybook/stories/cloud-agent/ProfileVarEditor.stories.tsx @@ -873,6 +873,7 @@ export const ManyVariables: Story = { mcpServers: [], skills: [], agents: [], + kiloCommands: [], }; return ; }, @@ -898,6 +899,7 @@ export const ManyCommands: Story = { mcpServers: [], skills: [], agents: [], + kiloCommands: [], }; return ; }, diff --git a/apps/storybook/stories/cloud-agent/ProfilesListDialog.stories.tsx b/apps/storybook/stories/cloud-agent/ProfilesListDialog.stories.tsx index dbb3658e60..07765421d1 100644 --- a/apps/storybook/stories/cloud-agent/ProfilesListDialog.stories.tsx +++ b/apps/storybook/stories/cloud-agent/ProfilesListDialog.stories.tsx @@ -605,6 +605,7 @@ export const ManyProfiles: Story = { mcpServerCount: Math.floor(Math.random() * 3), skillCount: Math.floor(Math.random() * 3), agentCount: Math.floor(Math.random() * 2), + kiloCommandCount: 0, })); return ; }, diff --git a/apps/web/src/components/cloud-agent-next/ChatInput.tsx b/apps/web/src/components/cloud-agent-next/ChatInput.tsx index 52034b8c76..554e2f2cff 100644 --- a/apps/web/src/components/cloud-agent-next/ChatInput.tsx +++ b/apps/web/src/components/cloud-agent-next/ChatInput.tsx @@ -8,6 +8,7 @@ import { Command, CommandList, CommandItem, CommandEmpty } from '@/components/ui import { Send, Square, Paperclip, Upload } from 'lucide-react'; import type { SlashCommand } from '@/lib/cloud-agent/slash-commands'; import { cn } from '@/lib/utils'; +import { useSlashCommandAutocomplete } from '@/hooks/useSlashCommandAutocomplete'; import { BrowseCommandsDialog } from './BrowseCommandsDialog'; import { ModeCombobox, NEXT_MODE_OPTIONS, type ModeOption } from '@/components/shared/ModeCombobox'; import { ModelCombobox, type ModelOption } from '@/components/shared/ModelCombobox'; @@ -28,6 +29,13 @@ import type { AgentMode } from './types'; type ChatInputProps = { onSend: (message: string, images?: Images) => Promise; + /** + * Invoked when the user submits a slash command (input starts with `/` + * and `` matches a known entry in `slashCommands`). When omitted or + * the input doesn't match a known command, the input is forwarded to + * `onSend` as plain text instead. + */ + onSendCommand?: (command: string, args: string, images?: Images) => Promise; onStop?: () => void; disabled?: boolean; isStreaming?: boolean; @@ -71,6 +79,7 @@ type ChatInputProps = { export function ChatInput({ onSend, + onSendCommand, onStop, disabled = false, isStreaming = false, @@ -95,11 +104,10 @@ export function ChatInput({ variantPickerTooltip, }: ChatInputProps) { const [value, setValue] = useState(''); - const [showAutocomplete, setShowAutocomplete] = useState(false); - const [selectedIndex, setSelectedIndex] = useState(0); const valueRef = useRef(''); const textareaRef = useRef(null); const fileInputRef = useRef(null); + const commandListRef = useRef(null); const setInputValue = useCallback((nextValue: string) => { valueRef.current = nextValue; @@ -132,34 +140,6 @@ export function ChatInput({ ? formatShortModelDisplayName(lockedModelOption.name) : model; - // Filter commands based on current input - const filteredCommands = useMemo(() => { - if (!slashCommands || slashCommands.length === 0) return []; - if (!value.startsWith('/')) return []; - - const query = value.slice(1).toLowerCase(); - return slashCommands.filter(cmd => cmd.trigger.toLowerCase().startsWith(query)); - }, [value, slashCommands]); - - // Determine if autocomplete should be shown - const shouldShowAutocomplete = useMemo(() => { - return ( - value.startsWith('/') && - filteredCommands.length > 0 && - slashCommands && - slashCommands.length > 0 - ); - }, [value, filteredCommands.length, slashCommands]); - - // Update showAutocomplete state when conditions change - useEffect(() => { - setShowAutocomplete(shouldShowAutocomplete); - // Reset selected index when filtering changes - if (shouldShowAutocomplete) { - setSelectedIndex(0); - } - }, [shouldShowAutocomplete]); - useEffect(() => { const textarea = textareaRef.current; if (!textarea) return; @@ -180,13 +160,27 @@ export function ChatInput({ setInputValue(''); submittedImageIds.forEach(imageUpload.removeImage); - setShowAutocomplete(false); if (textareaRef.current) { textareaRef.current.style.height = 'auto'; } - const accepted = await onSend(trimmed, imagesData); + // Re-match against the trimmed value at submit time + let accepted: boolean; + const slashMatch = onSendCommand + ? /^\s*\/([\w.-]+)(?:\s+([\s\S]*))?\s*$/.exec(trimmed) + : null; + const slashCommand = + slashMatch && slashCommands?.some(c => c.trigger === slashMatch[1]) + ? { command: slashMatch[1], args: slashMatch[2]?.trim() ?? '' } + : null; + + if (slashCommand && onSendCommand) { + accepted = await onSendCommand(slashCommand.command, slashCommand.args, imagesData); + } else { + accepted = await onSend(trimmed, imagesData); + } + if (!accepted) { if (valueRef.current === '') { setInputValue(trimmed); @@ -197,7 +191,7 @@ export function ChatInput({ return true; }, - [disabled, imageUpload, onSend, setInputValue] + [disabled, imageUpload, onSend, onSendCommand, setInputValue, slashCommands] ); const handleSend = () => { @@ -210,63 +204,44 @@ export function ChatInput({ } }; - const handleSelectCommand = (command: SlashCommand, autoSend = false) => { - const expansion = command.expansion; - setShowAutocomplete(false); - setSelectedIndex(0); - - if (autoSend) { - void sendMessage(expansion); - } else { - // Just fill the input for editing - setInputValue(expansion); - // Force height recalculation for expanded text - if (textareaRef.current) { - textareaRef.current.style.height = 'auto'; - textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`; + const handleSelectCommand = useCallback( + (command: SlashCommand, autoSend = false) => { + if (autoSend) { + void sendMessage(`/${command.trigger}`); + } else { + const inserted = `/${command.trigger} `; + setInputValue(inserted); + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`; + const end = inserted.length; + textareaRef.current.setSelectionRange(end, end); + } } - } - // Keep focus on textarea - textareaRef.current?.focus(); - }; + textareaRef.current?.focus(); + }, + [sendMessage, setInputValue] + ); + + const { + showAutocomplete, + selectedIndex, + setSelectedIndex, + filteredCommands, + handleKeyDown: handleAutocompleteKeyDown, + setShowAutocomplete, + } = useSlashCommandAutocomplete({ + value, + slashCommands, + onSelect: handleSelectCommand, + listRef: commandListRef, + }); const handleKeyDown = (e: KeyboardEvent) => { - // Ignore keyboard events during IME composition (Chinese, Japanese, Korean input) if (e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) return; - if (showAutocomplete && filteredCommands.length > 0) { - switch (e.key) { - case 'ArrowDown': - e.preventDefault(); - setSelectedIndex(prev => (prev + 1) % filteredCommands.length); - return; - case 'ArrowUp': - e.preventDefault(); - setSelectedIndex(prev => (prev - 1 + filteredCommands.length) % filteredCommands.length); - return; - case 'Enter': - e.preventDefault(); - // Bounds check to prevent race condition - if (selectedIndex >= 0 && selectedIndex < filteredCommands.length) { - // Enter = select and send; Shift+Enter = select and expand only - handleSelectCommand(filteredCommands[selectedIndex], !e.shiftKey); - } - return; - case 'Tab': - e.preventDefault(); - // Bounds check to prevent race condition - if (selectedIndex >= 0 && selectedIndex < filteredCommands.length) { - // Tab = select and expand only (don't send) - handleSelectCommand(filteredCommands[selectedIndex], false); - } - return; - case 'Escape': - e.preventDefault(); - setShowAutocomplete(false); - return; - } - } + if (handleAutocompleteKeyDown(e)) return; if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); @@ -368,8 +343,9 @@ export function ChatInput({ sideOffset={4} onOpenAutoFocus={e => e.preventDefault()} > - + handleSelectCommand(cmd)} - className={cn( - 'flex cursor-pointer flex-col items-start gap-1 px-3 py-2', - index === selectedIndex && 'bg-accent' - )} + className="flex cursor-pointer flex-col items-start gap-1 px-3 py-2" onMouseEnter={() => setSelectedIndex(index)} - role="option" - aria-selected={index === selectedIndex} >
diff --git a/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx b/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx index 760a19e7c7..93fc5622ed 100644 --- a/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx @@ -13,6 +13,7 @@ import { type KiloSessionId, type CloudAgentSessionId, } from '@/lib/cloud-agent-sdk'; +import type { SendMessagePayload } from '@/lib/cloud-agent-next/cloud-agent-client'; import { CLOUD_AGENT_NEXT_WS_URL, SESSION_INGEST_WS_URL } from '@/lib/constants'; import { usePostHog } from 'posthog-js/react'; @@ -109,37 +110,50 @@ export function CloudAgentProvider({ children, organizationId }: CloudAgentProvi lifecycleHooks: createBrowserLifecycleHooks(), api: { - send: async payload => { - const mode = payload.mode ?? 'code'; - if (payload.model === undefined) { - throw new Error('Cloud Agent model is required'); + send: async input => { + // The transport-level SendPromptPayload makes mode/model optional (CLI + // live transport accepts them as optional). The cloud-agent worker + // schema requires them for prompts, so coerce here and fail loudly + // if missing. + let normalizedPayload: SendMessagePayload; + if (input.payload.type === 'prompt') { + if (!input.payload.mode) throw new Error('Cloud Agent mode is required'); + if (!input.payload.model) throw new Error('Cloud Agent model is required'); + normalizedPayload = { + type: 'prompt', + prompt: input.payload.prompt, + mode: input.payload.mode, + model: input.payload.model, + variant: input.payload.variant, + }; + } else { + normalizedPayload = { + type: 'command', + command: input.payload.command, + arguments: input.payload.arguments, + }; } + if (organizationId) { return trpcClient.organizations.cloudAgentNext.sendMessage.mutate( { - cloudAgentSessionId: payload.sessionId, - prompt: payload.prompt, - mode, - model: payload.model, - variant: payload.variant, + cloudAgentSessionId: input.sessionId, + payload: normalizedPayload, autoCommit: true, organizationId, - messageId: payload.messageId, - images: payload.images, + messageId: input.messageId, + images: input.images, }, { context: { skipBatch: true } } ); } return trpcClient.cloudAgentNext.sendMessage.mutate( { - cloudAgentSessionId: payload.sessionId, - prompt: payload.prompt, - mode, - model: payload.model, - variant: payload.variant, + cloudAgentSessionId: input.sessionId, + payload: normalizedPayload, autoCommit: true, - messageId: payload.messageId, - images: payload.images, + messageId: input.messageId, + images: input.images, }, { context: { skipBatch: true } } ); diff --git a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx index 7a28db32f8..0304f09e5d 100644 --- a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx @@ -288,12 +288,15 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { ? selectedRuntimeAgentForSend?.variant?.trim() || undefined : undefined; const acceptedPromise = manager.send({ - prompt, - mode: sessionConfig?.mode ?? 'code', - model: agentModelOverrideForSend ?? sessionConfig?.model ?? '', - variant: agentModelOverrideForSend - ? agentVariantOverrideForSend - : (sessionConfig?.variant ?? undefined), + payload: { + type: 'prompt', + prompt, + mode: sessionConfig?.mode ?? 'code', + model: agentModelOverrideForSend ?? sessionConfig?.model ?? '', + variant: agentModelOverrideForSend + ? agentVariantOverrideForSend + : (sessionConfig?.variant ?? undefined), + }, images, }); scheduleScrollToBottom(); @@ -308,6 +311,24 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { [manager, scheduleScrollToBottom, sessionConfig, setChatUI] ); + const handleSendSlashCommand = useCallback( + async (command: string, args: string, images?: Images) => { + setChatUI({ shouldAutoScroll: true }); + const acceptedPromise = manager.send({ + payload: { type: 'command', command, arguments: args }, + images, + }); + scheduleScrollToBottom(); + const accepted = await acceptedPromise; + if (accepted) { + setImageMessageUuid(crypto.randomUUID()); + scheduleScrollToBottom(); + } + return accepted; + }, + [manager, scheduleScrollToBottom, setChatUI] + ); + const handleStopExecution = useCallback(() => { void manager.interrupt(); }, [manager]); @@ -555,6 +576,7 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) {
(null); const fileInputRef = useRef(null); + const commandListRef = useRef(null); const { mutateAsync: personalUploadUrl } = useMutation( trpc.cloudAgentNext.getImageUploadUrl.mutationOptions() ); @@ -565,6 +570,57 @@ export function NewSessionPanel({ organizationId }: NewSessionPanelProps) { [handleRepoSelect] ); + // --------------------------------------------------------------------------- + // Slash commands + // --------------------------------------------------------------------------- + const slashCommands = useMemo(() => { + const defaults = commandsOrDefault(undefined).map(cmd => ({ + trigger: cmd.name, + label: cmd.name, + description: cmd.description ?? '', + expansion: '', + })); + const profileCommands = (selectedProfileDetails?.kiloCommands ?? []) + .filter(cmd => cmd.enabled) + .map(cmd => ({ + trigger: cmd.name, + label: cmd.name, + description: cmd.description ?? '', + expansion: '', + })); + return [...defaults, ...profileCommands]; + }, [selectedProfileDetails?.kiloCommands]); + + const handleSelectCommand = useCallback((command: SlashCommand, autoSend = false) => { + if (autoSend) { + setPrompt(`/${command.trigger}`); + } else { + const inserted = `/${command.trigger} `; + setPrompt(inserted); + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, window.innerHeight * 0.5)}px`; + const end = inserted.length; + textareaRef.current.setSelectionRange(end, end); + } + } + textareaRef.current?.focus(); + }, []); + + const { + showAutocomplete, + selectedIndex, + setSelectedIndex, + filteredCommands, + handleKeyDown: handleAutocompleteKeyDown, + setShowAutocomplete, + } = useSlashCommandAutocomplete({ + value: prompt, + slashCommands, + onSelect: handleSelectCommand, + listRef: commandListRef, + }); + // --------------------------------------------------------------------------- // Submit // --------------------------------------------------------------------------- @@ -589,8 +645,27 @@ export function NewSessionPanel({ organizationId }: NewSessionPanelProps) { try { const initialMessageId = generateMessageId(); + const trimmed = prompt.trim(); + + // Parse slash command: if the input matches a known command, send a + // structured initialPayload so the backend dispatches a command rather + // than treating the text as a free-text prompt. + const slashMatch = /^\s*\/([\w.-]+)(?:\s+([\s\S]*))?\s*$/.exec(trimmed); + const slashCommand = + slashMatch && slashCommands.some(c => c.trigger === slashMatch[1]) + ? { command: slashMatch[1], args: slashMatch[2]?.trim() ?? '' } + : null; + + if (slashCommand && imageUpload.images.length > 0) { + toast.error('Images cannot be attached to slash commands', { + description: 'Remove the images or type a plain prompt instead.', + }); + setIsPreparing(false); + return; + } + const baseInput = { - prompt: prompt.trim(), + prompt: trimmed, mode, model: displayModel, variant: displayVariant, @@ -599,6 +674,15 @@ export function NewSessionPanel({ organizationId }: NewSessionPanelProps) { autoInitiate: true, initialMessageId, images: imageUpload.getImagesData(), + ...(slashCommand + ? { + initialPayload: { + type: 'command' as const, + command: slashCommand.command, + arguments: slashCommand.args, + }, + } + : {}), }; let result: { kiloSessionId: string; cloudAgentSessionId: string }; @@ -677,6 +761,7 @@ export function NewSessionPanel({ organizationId }: NewSessionPanelProps) { selectedPlatform, selectedProfileId, selectedRepo, + slashCommands, trpc.cliSessionsV2.list, trpcClient, ]); @@ -717,6 +802,10 @@ export function NewSessionPanel({ organizationId }: NewSessionPanelProps) { const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { + if (e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) return; + + if (handleAutocompleteKeyDown(e)) return; + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); if (isFormValid) { @@ -724,7 +813,7 @@ export function NewSessionPanel({ organizationId }: NewSessionPanelProps) { } } }, - [isFormValid, handleStartSession] + [handleAutocompleteKeyDown, isFormValid, handleStartSession] ); // --------------------------------------------------------------------------- @@ -827,18 +916,60 @@ export function NewSessionPanel({ organizationId }: NewSessionPanelProps) { } }} /> -