From 83285d1471b1a595bffc4a2ba0314468554d1f0f Mon Sep 17 00:00:00 2001 From: william arzt Date: Fri, 1 May 2026 16:14:17 -0400 Subject: [PATCH 1/3] Add slash command menu --- .../app/ui/components/BareChatInput/index.tsx | 155 ++++++++-- .../BareChatInput/useSlashCommands.tsx | 266 ++++++++++++++++++ .../MessageInput/InputSlashCommandPopup.tsx | 46 +++ .../MessageInput/MessageInputBase.tsx | 28 +- .../app/ui/components/SlashCommandPopup.tsx | 119 ++++++++ 5 files changed, 587 insertions(+), 27 deletions(-) create mode 100644 packages/app/ui/components/BareChatInput/useSlashCommands.tsx create mode 100644 packages/app/ui/components/MessageInput/InputSlashCommandPopup.tsx create mode 100644 packages/app/ui/components/SlashCommandPopup.tsx diff --git a/packages/app/ui/components/BareChatInput/index.tsx b/packages/app/ui/components/BareChatInput/index.tsx index 0b8b45520a..abdb2adbfd 100644 --- a/packages/app/ui/components/BareChatInput/index.tsx +++ b/packages/app/ui/components/BareChatInput/index.tsx @@ -53,6 +53,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 { @@ -60,11 +61,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); @@ -293,6 +335,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); @@ -343,6 +393,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) => { @@ -358,15 +419,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); @@ -383,18 +443,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( @@ -413,6 +480,26 @@ function BareChatInput( [handleSelectMention, controlledText] ); + const onSlashCommandSelect = useCallback( + (option: SlashCommandOption) => { + const newText = handleSelectSlashCommand(option, controlledText); + + if (!newText) { + return; + } + + setControlledText(newText); + + const jsonContent = textAndMentionsToContent(newText, mentions); + bareChatInputLogger.log('setting draft', jsonContent); + storeDraft(jsonContent); + + // Force focus back to input after slash command selection + inputRef.current?.focus(); + }, + [handleSelectSlashCommand, controlledText, mentions, storeDraft] + ); + const sendMessage = useCallback( async (isEdit?: boolean) => { const jsonContent = textAndMentionsToContent(controlledText, mentions); @@ -810,14 +897,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); @@ -841,38 +928,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) { + e.preventDefault?.(); + slashCommandRef.current?.handleSlashCommandKey(keyEvent.key); + return; + } + + if (isMentionModeActive) { + e.preventDefault?.(); + mentionRef.current?.handleMentionKey(keyEvent.key); + return; + } } if (keyEvent.key === 'Escape') { + if (isSlashCommandModeActive) { + 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(); @@ -883,9 +985,12 @@ function BareChatInput( }, [ isMentionModeActive, + isSlashCommandModeActive, setIsOpen, handleMentionEscape, + handleSlashCommandEscape, hasMentionCandidates, + hasSlashCommandCandidates, editingPost, disableSend, handleEdit, @@ -902,10 +1007,14 @@ function BareChatInput( sendError={sendError} showWayfindingTooltip={showWayfindingTooltip} 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..7cf96b55be --- /dev/null +++ b/packages/app/ui/components/BareChatInput/useSlashCommands.tsx @@ -0,0 +1,266 @@ +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 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 && !afterCursor.includes(' '))) + ) { + setIsSlashCommandModeActive(true); + setSlashCommandStartIndex(lastSlashIndex); + setSlashCommandSearchText(textBetweenSlashAndCursor); + } else { + resetSlashCommand(); + } + }; + + const handleSelectSlashCommand = ( + option: SlashCommandOption, + text: string + ) => { + if (slashCommandStartIndex === null) { + return; + } + + const insertText = option.insertText ?? option.command; + const beforeSlash = text.slice(0, slashCommandStartIndex); + const afterSlash = text.slice( + slashCommandStartIndex + slashCommandSearchText.length + 1 + ); + const spacer = insertText.endsWith(' ') ? '' : ' '; + const newText = `${beforeSlash}${insertText}${spacer}${afterSlash}`; + + resetSlashCommand(); + setWasDismissedByEscape(false); + setLastDismissedTriggerIndex(null); + + return newText; + }; + + 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 4f347b4bc4..5730cdbdab 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; @@ -83,6 +86,7 @@ export const MessageInputContainer = memo( containerHeight, sendError, isMentionModeActive = false, + isSlashCommandModeActive = false, showAttachmentButton = true, floatingActionButton = false, showWayfindingTooltip = false, @@ -90,12 +94,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; @@ -103,6 +110,7 @@ export const MessageInputContainer = memo( containerHeight: number; sendError: boolean; isMentionModeActive?: boolean; + isSlashCommandModeActive?: boolean; showAttachmentButton?: boolean; floatingActionButton?: boolean; showWayfindingTooltip?: boolean; @@ -110,12 +118,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(); @@ -140,6 +151,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; From 02f1820e5bd7de3a7c164860f595999c57f3f602 Mon Sep 17 00:00:00 2001 From: william arzt Date: Fri, 1 May 2026 16:24:31 -0400 Subject: [PATCH 2/3] Treat whitespace as slash command terminator --- packages/app/ui/components/BareChatInput/useSlashCommands.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/ui/components/BareChatInput/useSlashCommands.tsx b/packages/app/ui/components/BareChatInput/useSlashCommands.tsx index 7cf96b55be..9aa73a0b57 100644 --- a/packages/app/ui/components/BareChatInput/useSlashCommands.tsx +++ b/packages/app/ui/components/BareChatInput/useSlashCommands.tsx @@ -215,7 +215,7 @@ export const useSlashCommands = ( !spaceAfterSlash && !isDismissedTrigger && (cursorPosition === lastSlashIndex + 1 || - (cursorPosition > lastSlashIndex && !afterCursor.includes(' '))) + (cursorPosition > lastSlashIndex && !/\s/.test(afterCursor))) ) { setIsSlashCommandModeActive(true); setSlashCommandStartIndex(lastSlashIndex); From 8ab796e1fa14d7a2d5cb93c6dc14292d8671172a Mon Sep 17 00:00:00 2001 From: Patrick O'Sullivan Date: Tue, 5 May 2026 16:10:12 -0500 Subject: [PATCH 3/3] Fix slash command key handling and mention offsets --- .../app/ui/components/BareChatInput/index.tsx | 33 +++++++++++++++---- .../BareChatInput/useSlashCommands.tsx | 20 ++++++++--- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/packages/app/ui/components/BareChatInput/index.tsx b/packages/app/ui/components/BareChatInput/index.tsx index abdb2adbfd..fadb9170ef 100644 --- a/packages/app/ui/components/BareChatInput/index.tsx +++ b/packages/app/ui/components/BareChatInput/index.tsx @@ -482,22 +482,43 @@ function BareChatInput( const onSlashCommandSelect = useCallback( (option: SlashCommandOption) => { - const newText = handleSelectSlashCommand(option, controlledText); + const selection = handleSelectSlashCommand(option, controlledText); - if (!newText) { + 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, mentions); + 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, storeDraft] + [ + handleSelectSlashCommand, + controlledText, + mentions, + setMentions, + storeDraft, + ] ); const sendMessage = useCallback( @@ -943,7 +964,7 @@ function BareChatInput( } if (keyEvent.key === 'ArrowUp' || keyEvent.key === 'ArrowDown') { - if (isSlashCommandModeActive) { + if (isSlashCommandModeActive && hasSlashCommandCandidates) { e.preventDefault?.(); slashCommandRef.current?.handleSlashCommandKey(keyEvent.key); return; @@ -957,7 +978,7 @@ function BareChatInput( } if (keyEvent.key === 'Escape') { - if (isSlashCommandModeActive) { + if (isSlashCommandModeActive && hasSlashCommandCandidates) { e.preventDefault?.(); handleSlashCommandEscape(); return; diff --git a/packages/app/ui/components/BareChatInput/useSlashCommands.tsx b/packages/app/ui/components/BareChatInput/useSlashCommands.tsx index 9aa73a0b57..83ad99387c 100644 --- a/packages/app/ui/components/BareChatInput/useSlashCommands.tsx +++ b/packages/app/ui/components/BareChatInput/useSlashCommands.tsx @@ -11,6 +11,12 @@ export interface SlashCommandOption { insertText?: string; } +export interface SlashCommandSelection { + text: string; + startIndex: number; + delta: number; +} + export const DEFAULT_SLASH_COMMANDS: SlashCommandOption[] = [ { command: '/owner-listen', @@ -228,24 +234,28 @@ export const useSlashCommands = ( 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 + slashCommandSearchText.length + 1 - ); + 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 newText; + return { + text: newText, + startIndex: slashCommandStartIndex, + delta, + }; }; const handleSlashCommandEscape = () => {