diff --git a/packages/app/ui/components/BareChatInput/index.tsx b/packages/app/ui/components/BareChatInput/index.tsx index 55bdf13a20..ba48bb5903 100644 --- a/packages/app/ui/components/BareChatInput/index.tsx +++ b/packages/app/ui/components/BareChatInput/index.tsx @@ -54,6 +54,7 @@ import { MessageInputProps, } from '../MessageInput/MessageInputBase'; import { hydrateEditPost } from '../MessageInput/helpers'; +import { type SlashCommandController } from '../SlashCommandPopup'; import type { DraftInputHandle } from '../draftInputs/shared'; import { contentToTextAndMentions, textAndMentionsToContent } from './helpers'; import { @@ -61,11 +62,52 @@ import { createMentionRoleOptions, useMentions, } from './useMentions'; +import { type SlashCommandOption, useSlashCommands } from './useSlashCommands'; const bareChatInputLogger = createDevLogger('bareChatInput', false); const DEFAULT_KEYBOARD_HEIGHT = 300; +type WebTextInputRef = TextInput & { selectionStart?: number }; + +type TextInputSizeEvent = { + target?: unknown; +}; + +type ResizableTextInputTarget = { + style: { height: string | number }; + offsetHeight: number; + clientHeight: number; + scrollHeight: number; +}; + +type ComposerKeyPressEvent = { + preventDefault?: () => void; + nativeEvent: { + key: string; + shiftKey?: boolean; + metaKey?: boolean; + ctrlKey?: boolean; + }; +}; + +function isResizableTextInputTarget( + target: unknown +): target is ResizableTextInputTarget { + if (typeof target !== 'object' || target === null) { + return false; + } + + const candidate = target as Partial; + return ( + typeof candidate.style === 'object' && + candidate.style !== null && + typeof candidate.offsetHeight === 'number' && + typeof candidate.clientHeight === 'number' && + typeof candidate.scrollHeight === 'number' + ); +} + function useKeyboardHeight(maxInputHeightBasic: number) { const [maxInputHeight, setMaxInputHeight] = useState(maxInputHeightBasic); @@ -296,6 +338,14 @@ function BareChatInput( handleSelectMention, handleMentionEscape, } = useMentions({ chatId: groupId ?? channelId, roleOptions }); + const { + validSlashCommands, + isSlashCommandModeActive, + hasSlashCommandCandidates, + handleSlashCommand, + handleSelectSlashCommand, + handleSlashCommandEscape, + } = useSlashCommands(); const maxInputHeight = useKeyboardHeight(maxInputHeightBasic); const inputRef = useRef(null); @@ -346,6 +396,17 @@ function BareChatInput( const lastProcessedRef = useRef(''); const mentionRef = useRef(null); + const slashCommandRef = useRef(null); + + const getWebSelectionStart = useCallback(() => { + if (!isWeb) { + return undefined; + } + + const selectionStart = (inputRef.current as WebTextInputRef | null) + ?.selectionStart; + return typeof selectionStart === 'number' ? selectionStart : undefined; + }, []); const handleTextChange = useCallback( (newText: string) => { @@ -361,15 +422,14 @@ function BareChatInput( if (REF_REGEX.test(newText) && lastProcessedRef.current !== newText) { lastProcessedRef.current = newText; const textWithoutRefs = processReferences(newText); - const cursorPos = isWeb - ? (inputRef.current as any)?.selectionStart - : undefined; + const cursorPos = getWebSelectionStart(); const adjustedCursorPos = cursorPos != null ? Math.max(0, cursorPos - (newText.length - textWithoutRefs.length)) : undefined; setControlledText(textWithoutRefs); handleMention(oldText, textWithoutRefs, adjustedCursorPos); + handleSlashCommand(oldText, textWithoutRefs, adjustedCursorPos); const jsonContent = textAndMentionsToContent(textWithoutRefs, mentions); bareChatInputLogger.log('setting draft', jsonContent); @@ -386,18 +446,25 @@ function BareChatInput( } } else if (!REF_REGEX.test(newText)) { // if there's no reference to process, just update normally - const cursorPos = isWeb - ? (inputRef.current as any)?.selectionStart - : undefined; + const cursorPos = getWebSelectionStart(); setControlledText(newText); handleMention(oldText, newText, cursorPos); + handleSlashCommand(oldText, newText, cursorPos); const jsonContent = textAndMentionsToContent(newText, mentions); bareChatInputLogger.log('setting draft', jsonContent); storeDraft(jsonContent); } }, - [controlledText, processReferences, handleMention, mentions, storeDraft] + [ + controlledText, + processReferences, + getWebSelectionStart, + handleMention, + handleSlashCommand, + mentions, + storeDraft, + ] ); const onMentionSelect = useCallback( @@ -416,6 +483,47 @@ function BareChatInput( [handleSelectMention, controlledText] ); + const onSlashCommandSelect = useCallback( + (option: SlashCommandOption) => { + const selection = handleSelectSlashCommand(option, controlledText); + + if (!selection) { + return; + } + + const newText = selection.text; + const updatedMentions = + selection.delta === 0 + ? mentions + : mentions.map((mention) => + mention.start >= selection.startIndex + ? { + ...mention, + start: mention.start + selection.delta, + end: mention.end + selection.delta, + } + : mention + ); + + setControlledText(newText); + setMentions(updatedMentions); + + const jsonContent = textAndMentionsToContent(newText, updatedMentions); + bareChatInputLogger.log('setting draft', jsonContent); + storeDraft(jsonContent); + + // Force focus back to input after slash command selection + inputRef.current?.focus(); + }, + [ + handleSelectSlashCommand, + controlledText, + mentions, + setMentions, + storeDraft, + ] + ); + const sendMessage = useCallback( async (isEdit?: boolean) => { const jsonContent = textAndMentionsToContent(controlledText, mentions); @@ -813,14 +921,14 @@ function BareChatInput( }; const inputTextColor = getVariableValue(theme.primaryText); - const adjustTextInputSize = (e: any) => { + const adjustTextInputSize = (e: TextInputSizeEvent) => { if (!isWeb) { return; } - const el = e?.target; - if (el && 'style' in el && 'height' in el.style) { - el.style.height = 0; + const el = e.target; + if (isResizableTextInputTarget(el)) { + el.style.height = '0px'; const newHeight = el.offsetHeight - el.clientHeight + el.scrollHeight; el.style.height = `${newHeight}px`; setInputHeight(newHeight); @@ -850,38 +958,53 @@ function BareChatInput( }, [channelId]); const handleKeyPress = useCallback( - (e: any) => { - const keyEvent = e.nativeEvent as unknown as KeyboardEvent; + (e: ComposerKeyPressEvent) => { + const keyEvent = e.nativeEvent; if (!isWeb) return; if ( (keyEvent.metaKey || keyEvent.ctrlKey) && keyEvent.key.toLowerCase() === 'k' ) { - e.preventDefault(); + e.preventDefault?.(); inputRef.current?.blur(); setIsOpen(true); return; } - if ( - (keyEvent.key === 'ArrowUp' || keyEvent.key === 'ArrowDown') && - isMentionModeActive - ) { - e.preventDefault(); - mentionRef.current?.handleMentionKey(keyEvent.key); + if (keyEvent.key === 'ArrowUp' || keyEvent.key === 'ArrowDown') { + if (isSlashCommandModeActive && hasSlashCommandCandidates) { + e.preventDefault?.(); + slashCommandRef.current?.handleSlashCommandKey(keyEvent.key); + return; + } + + if (isMentionModeActive) { + e.preventDefault?.(); + mentionRef.current?.handleMentionKey(keyEvent.key); + return; + } } if (keyEvent.key === 'Escape') { + if (isSlashCommandModeActive && hasSlashCommandCandidates) { + e.preventDefault?.(); + handleSlashCommandEscape(); + return; + } + if (isMentionModeActive) { - e.preventDefault(); + e.preventDefault?.(); handleMentionEscape(); + return; } } if (keyEvent.key === 'Enter' && !keyEvent.shiftKey) { - e.preventDefault(); - if (isMentionModeActive && hasMentionCandidates) { + e.preventDefault?.(); + if (isSlashCommandModeActive && hasSlashCommandCandidates) { + slashCommandRef.current?.handleSlashCommandKey('Enter'); + } else if (isMentionModeActive && hasMentionCandidates) { mentionRef.current?.handleMentionKey('Enter'); } else if (editingPost) { handleEdit(); @@ -892,9 +1015,12 @@ function BareChatInput( }, [ isMentionModeActive, + isSlashCommandModeActive, setIsOpen, handleMentionEscape, + handleSlashCommandEscape, hasMentionCandidates, + hasSlashCommandCandidates, editingPost, disableSend, handleEdit, @@ -912,10 +1038,14 @@ function BareChatInput( showWayfindingTooltip={showWayfindingTooltip} showBotMentionTooltip={showBotMentionTooltip} isMentionModeActive={isMentionModeActive} + isSlashCommandModeActive={isSlashCommandModeActive} mentionText={mentionSearchText} mentionOptions={validOptions} + slashCommandOptions={validSlashCommands} mentionRef={mentionRef} + slashCommandRef={slashCommandRef} onSelectMention={onMentionSelect} + onSelectSlashCommand={onSlashCommandSelect} showAttachmentButton={showAttachmentButton} isEditing={!!editingPost} cancelEditing={handleCancelEditing} diff --git a/packages/app/ui/components/BareChatInput/useSlashCommands.tsx b/packages/app/ui/components/BareChatInput/useSlashCommands.tsx new file mode 100644 index 0000000000..83ad99387c --- /dev/null +++ b/packages/app/ui/components/BareChatInput/useSlashCommands.tsx @@ -0,0 +1,276 @@ +import { type IconType } from '@tloncorp/ui'; +import { useMemo, useState } from 'react'; + +export interface SlashCommandOption { + command: `/${string}`; + title: string; + subtitle?: string; + icon: IconType; + keywords?: string[]; + priority: number; + insertText?: string; +} + +export interface SlashCommandSelection { + text: string; + startIndex: number; + delta: number; +} + +export const DEFAULT_SLASH_COMMANDS: SlashCommandOption[] = [ + { + command: '/owner-listen', + title: 'Owner listen', + subtitle: 'Let the owner session listen in this channel', + icon: 'Command', + keywords: ['owner', 'listen', 'agent'], + priority: 1, + }, + { + command: '/status', + title: 'Status', + subtitle: 'Show the current OpenClaw session status', + icon: 'Info', + keywords: ['openclaw', 'session', 'model'], + priority: 2, + }, + { + command: '/help', + title: 'Help', + subtitle: 'Show available OpenClaw commands', + icon: 'Info', + keywords: ['openclaw', 'commands'], + priority: 3, + }, + { + command: '/new', + title: 'New session', + subtitle: 'Start a fresh OpenClaw session', + icon: 'Add', + keywords: ['reset', 'session', 'openclaw'], + priority: 4, + }, + { + command: '/pending', + title: 'Pending approvals', + subtitle: 'List pending DM, channel, and group requests', + icon: 'Clock', + keywords: ['approval', 'requests', 'owner'], + priority: 5, + }, + { + command: '/allow', + title: 'Allow request', + subtitle: 'Approve a pending request by id', + icon: 'Checkmark', + keywords: ['approve', 'approval', 'request'], + priority: 6, + }, + { + command: '/reject', + title: 'Reject request', + subtitle: 'Decline a pending request by id', + icon: 'Close', + keywords: ['deny', 'decline', 'approval', 'request'], + priority: 7, + }, + { + command: '/ban', + title: 'Ban request', + subtitle: 'Block a ship and deny its pending request', + icon: 'EyeClosed', + keywords: ['block', 'deny', 'ship', 'approval'], + priority: 8, + }, + { + command: '/banned', + title: 'Banned ships', + subtitle: 'List currently banned ships', + icon: 'EyeClosed', + keywords: ['blocked', 'ships', 'list'], + priority: 9, + }, + { + command: '/unban', + title: 'Unban ship', + subtitle: 'Remove a ship from the ban list', + icon: 'EyeOpen', + keywords: ['unblock', 'ship', 'allow'], + priority: 10, + }, + { + command: '/tlon-version', + title: 'Tlon plugin version', + subtitle: 'Show the installed OpenClaw Tlon plugin version', + icon: 'Info', + keywords: ['version', 'plugin', 'openclaw'], + priority: 11, + }, +]; + +function matchesCommand(option: SlashCommandOption, query: string) { + if (query.trim().length === 0) { + return true; + } + + const normalized = query.toLowerCase(); + const command = option.command.slice(1).toLowerCase(); + const title = option.title.toLowerCase(); + const keywords = option.keywords ?? []; + + return ( + command.includes(normalized) || + title.includes(normalized) || + keywords.some((keyword) => keyword.toLowerCase().includes(normalized)) + ); +} + +export const useSlashCommands = ( + commands: SlashCommandOption[] = DEFAULT_SLASH_COMMANDS +) => { + const [isSlashCommandModeActive, setIsSlashCommandModeActive] = + useState(false); + const [slashCommandStartIndex, setSlashCommandStartIndex] = useState< + number | null + >(null); + const [slashCommandSearchText, setSlashCommandSearchText] = useState(''); + const [wasDismissedByEscape, setWasDismissedByEscape] = useState(false); + const [lastDismissedTriggerIndex, setLastDismissedTriggerIndex] = useState< + number | null + >(null); + + const validSlashCommands = useMemo(() => { + return commands + .filter((option) => matchesCommand(option, slashCommandSearchText)) + .sort((a, b) => a.priority - b.priority); + }, [commands, slashCommandSearchText]); + + const hasSlashCommandCandidates = validSlashCommands.length > 0; + + const resetSlashCommand = () => { + setIsSlashCommandModeActive(false); + setSlashCommandStartIndex(null); + setSlashCommandSearchText(''); + }; + + const handleSlashCommand = ( + oldText: string, + newText: string, + cursorPositionOverride?: number + ) => { + // Mirrors the mention parser. When the input can provide a cursor position + // we use it; otherwise we infer the edit point from the text diff for native + // TextInput compatibility. + let cursorPosition = cursorPositionOverride ?? newText.length; + if (cursorPositionOverride == null && oldText.length !== newText.length) { + for (let i = 0; i < Math.min(oldText.length, newText.length); i++) { + if (oldText[i] !== newText[i]) { + cursorPosition = i + (newText.length > oldText.length ? 1 : 0); + break; + } + } + } + + if ( + oldText.length < newText.length && + newText[cursorPosition - 1]?.match(/\s/) + ) { + setWasDismissedByEscape(false); + } + + if (wasDismissedByEscape && lastDismissedTriggerIndex !== null) { + if ( + oldText.length > newText.length && + cursorPosition <= lastDismissedTriggerIndex + ) { + setWasDismissedByEscape(false); + setLastDismissedTriggerIndex(null); + } + } + + if (newText.length < oldText.length && isSlashCommandModeActive) { + const deletedChar = oldText[cursorPosition]; + if (deletedChar === '/') { + resetSlashCommand(); + return; + } + } + + const beforeCursor = newText.slice(0, cursorPosition); + const afterCursor = newText.slice(cursorPosition); + const lastSlashIndex = beforeCursor.lastIndexOf('/'); + + if (lastSlashIndex < 0) { + resetSlashCommand(); + return; + } + + const textBetweenSlashAndCursor = beforeCursor.slice(lastSlashIndex + 1); + const textBeforeSlash = beforeCursor.slice( + lastSlashIndex - 1, + lastSlashIndex + ); + const whitespaceBeforeOrFirst = + lastSlashIndex === 0 || !!textBeforeSlash.match(/\s/); + const spaceAfterSlash = textBetweenSlashAndCursor.match(/\s/); + const isDismissedTrigger = + wasDismissedByEscape && lastSlashIndex === lastDismissedTriggerIndex; + + if ( + whitespaceBeforeOrFirst && + !spaceAfterSlash && + !isDismissedTrigger && + (cursorPosition === lastSlashIndex + 1 || + (cursorPosition > lastSlashIndex && !/\s/.test(afterCursor))) + ) { + setIsSlashCommandModeActive(true); + setSlashCommandStartIndex(lastSlashIndex); + setSlashCommandSearchText(textBetweenSlashAndCursor); + } else { + resetSlashCommand(); + } + }; + + const handleSelectSlashCommand = ( + option: SlashCommandOption, + text: string + ): SlashCommandSelection | undefined => { + if (slashCommandStartIndex === null) { + return; + } + + const insertText = option.insertText ?? option.command; + const replacedLength = slashCommandSearchText.length + 1; + const beforeSlash = text.slice(0, slashCommandStartIndex); + const afterSlash = text.slice(slashCommandStartIndex + replacedLength); + const spacer = insertText.endsWith(' ') ? '' : ' '; + const newText = `${beforeSlash}${insertText}${spacer}${afterSlash}`; + const delta = insertText.length + spacer.length - replacedLength; + + resetSlashCommand(); + setWasDismissedByEscape(false); + setLastDismissedTriggerIndex(null); + + return { + text: newText, + startIndex: slashCommandStartIndex, + delta, + }; + }; + + const handleSlashCommandEscape = () => { + resetSlashCommand(); + setWasDismissedByEscape(true); + setLastDismissedTriggerIndex(slashCommandStartIndex); + }; + + return { + validSlashCommands, + slashCommandSearchText, + handleSlashCommand, + handleSelectSlashCommand, + handleSlashCommandEscape, + isSlashCommandModeActive, + hasSlashCommandCandidates, + }; +}; diff --git a/packages/app/ui/components/MessageInput/InputSlashCommandPopup.tsx b/packages/app/ui/components/MessageInput/InputSlashCommandPopup.tsx new file mode 100644 index 0000000000..f62af0fcdd --- /dev/null +++ b/packages/app/ui/components/MessageInput/InputSlashCommandPopup.tsx @@ -0,0 +1,46 @@ +import React, { PropsWithRef } from 'react'; +import { View, YStack } from 'tamagui'; + +import { type SlashCommandOption } from '../BareChatInput/useSlashCommands'; +import { useIsWindowNarrow } from '../Emoji'; +import SlashCommandPopup, { + type SlashCommandPopupRef, +} from '../SlashCommandPopup'; + +function InputSlashCommandPopupInternal( + { + containerHeight, + isSlashCommandModeActive, + options, + onSelectSlashCommand, + }: PropsWithRef<{ + containerHeight: number; + isSlashCommandModeActive: boolean; + options: SlashCommandOption[]; + onSelectSlashCommand: (option: SlashCommandOption) => void; + }>, + ref: SlashCommandPopupRef +) { + const isNarrow = useIsWindowNarrow(); + + return isSlashCommandModeActive ? ( + + + + + + ) : null; +} + +const InputSlashCommandPopup = React.forwardRef(InputSlashCommandPopupInternal); +export default InputSlashCommandPopup; diff --git a/packages/app/ui/components/MessageInput/MessageInputBase.tsx b/packages/app/ui/components/MessageInput/MessageInputBase.tsx index fca2c2222d..e98534420e 100644 --- a/packages/app/ui/components/MessageInput/MessageInputBase.tsx +++ b/packages/app/ui/components/MessageInput/MessageInputBase.tsx @@ -1,5 +1,5 @@ import type { BridgeState, EditorBridge } from '@10play/tentap-editor'; -import { JSONContent, Story } from '@tloncorp/api/urbit'; +import { JSONContent } from '@tloncorp/api/urbit'; import * as db from '@tloncorp/shared/db'; import type * as domain from '@tloncorp/shared/domain'; import { Button, FloatingActionButton, Icon } from '@tloncorp/ui'; @@ -17,12 +17,15 @@ import { } from 'tamagui'; import { useAttachmentContext } from '../../contexts/attachment'; -import { MentionOption } from '../BareChatInput/useMentions'; -import { MentionPopupRef } from '../MentionPopup'; +import { type MentionOption } from '../BareChatInput/useMentions'; +import { type SlashCommandOption } from '../BareChatInput/useSlashCommands'; +import { type MentionPopupRef } from '../MentionPopup'; +import { type SlashCommandPopupRef } from '../SlashCommandPopup'; import Notices from '../Wayfinding/Notices'; -import { GalleryDraftType, useDraftInputContext } from '../draftInputs/shared'; +import { GalleryDraftType } from '../draftInputs/shared'; import AttachmentButton from './AttachmentButton'; import InputMentionPopup from './InputMentionPopup'; +import InputSlashCommandPopup from './InputSlashCommandPopup'; export interface MessageInputProps { shouldBlur: boolean; @@ -84,6 +87,7 @@ export const MessageInputContainer = memo( containerHeight, sendError, isMentionModeActive = false, + isSlashCommandModeActive = false, showAttachmentButton = true, floatingActionButton = false, showWayfindingTooltip = false, @@ -92,12 +96,15 @@ export const MessageInputContainer = memo( isSending = false, mentionText, mentionOptions, + slashCommandOptions = [], onSelectMention, + onSelectSlashCommand, isEditing = false, cancelEditing, onPressEdit, goBack, mentionRef, + slashCommandRef, frameless = false, }: PropsWithChildren<{ setShouldBlur: (shouldBlur: boolean) => void; @@ -105,6 +112,7 @@ export const MessageInputContainer = memo( containerHeight: number; sendError: boolean; isMentionModeActive?: boolean; + isSlashCommandModeActive?: boolean; showAttachmentButton?: boolean; floatingActionButton?: boolean; showWayfindingTooltip?: boolean; @@ -113,12 +121,15 @@ export const MessageInputContainer = memo( isSending?: boolean; mentionText?: string; mentionOptions: MentionOption[]; + slashCommandOptions?: SlashCommandOption[]; onSelectMention: (option: MentionOption) => void; + onSelectSlashCommand?: (option: SlashCommandOption) => void; isEditing?: boolean; cancelEditing?: () => void; onPressEdit?: () => void; goBack?: () => void; mentionRef?: MentionPopupRef; + slashCommandRef?: SlashCommandPopupRef; frameless?: boolean; }>) => { const { canUpload } = useAttachmentContext(); @@ -143,6 +154,15 @@ export const MessageInputContainer = memo( onSelectMention={onSelectMention} ref={mentionRef} /> + {onSelectSlashCommand ? ( + + ) : null} {!frameless ? ( ; + +function SlashCommandOptionItem({ + selected, + option, + onPress, +}: { + selected: boolean; + option: SlashCommandOption; + onPress: (option: SlashCommandOption) => void; +}) { + const handlePress = useBoundHandler(option, onPress); + const icon = option.icon as IconType; + const subtitle = option.subtitle + ? `${option.command} ยท ${option.subtitle}` + : option.command; + + return ( + + + + + {option.title} + {subtitle} + + + + ); +} + +function SlashCommandPopupInternal( + { + options, + onPress, + }: PropsWithRef<{ + options: SlashCommandOption[]; + onPress: (option: SlashCommandOption) => void; + }>, + ref: React.Ref +) { + const subSet = useMemo(() => options.slice(0, 7), [options]); + const [selectedIndex, setSelectedIndex] = useState(0); + + useEffect(() => { + setSelectedIndex(0); + }, [subSet.length]); + + useImperativeHandle(ref, () => ({ + handleSlashCommandKey(key) { + switch (key) { + case 'ArrowUp': + setSelectedIndex((prevIndex) => + prevIndex > 0 ? prevIndex - 1 : prevIndex + ); + break; + case 'ArrowDown': + setSelectedIndex((prevIndex) => + prevIndex < subSet.length - 1 ? prevIndex + 1 : prevIndex + ); + break; + case 'Enter': + if (subSet[selectedIndex]) { + onPress(subSet[selectedIndex]); + } + break; + } + }, + })); + + if (subSet.length === 0) { + return null; + } + + return ( + + {subSet.map((option, index) => { + return ( + + ); + })} + + ); +} + +const SlashCommandPopup = React.forwardRef(SlashCommandPopupInternal); +export default SlashCommandPopup;