From 98792a696c91d45f1d61d67a6817c2e880c0dd30 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 21 Apr 2026 00:26:00 +0800 Subject: [PATCH] feat(message-input): add search box to slash-command popups Both the inline autocomplete (triggered by `/` in the textarea) and the dropdown popup (triggered by the slash-command button) now show a search field at the top. Matching uses substring on name and description, and ranks name matches above description/id-only matches. --- src/components/chat/message-input.tsx | 337 ++++++++++++++++++++------ src/i18n/messages/ar.json | 4 +- src/i18n/messages/de.json | 4 +- src/i18n/messages/en.json | 4 +- src/i18n/messages/es.json | 4 +- src/i18n/messages/fr.json | 4 +- src/i18n/messages/ja.json | 4 +- src/i18n/messages/ko.json | 4 +- src/i18n/messages/pt.json | 4 +- src/i18n/messages/zh-CN.json | 4 +- src/i18n/messages/zh-TW.json | 4 +- 11 files changed, 293 insertions(+), 84 deletions(-) diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index f15763a..5fba2b7 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -20,6 +20,7 @@ import { GitFork, ListPlus, Plus, + Search, Send, Command, Sparkles, @@ -543,19 +544,58 @@ export function MessageInput({ () => (availableCommands ?? []).filter((cmd) => !expertIdSet.has(cmd.name)), [availableCommands, expertIdSet] ) + const [slashDropdownOpen, setSlashDropdownOpen] = useState(false) + const [slashDropdownSearch, setSlashDropdownSearch] = useState("") + const slashDropdownInputRef = useRef(null) + const filteredSlashDropdownCommands = useMemo(() => { + const q = slashDropdownSearch.toLowerCase().trim() + if (!q) return slashCommands + const nameMatches: typeof slashCommands = [] + const descOnlyMatches: typeof slashCommands = [] + for (const cmd of slashCommands) { + if (cmd.name.toLowerCase().includes(q)) { + nameMatches.push(cmd) + } else if (cmd.description?.toLowerCase().includes(q)) { + descOnlyMatches.push(cmd) + } + } + return [...nameMatches, ...descOnlyMatches] + }, [slashCommands, slashDropdownSearch]) + const handleSlashDropdownOpenChange = useCallback((open: boolean) => { + setSlashDropdownOpen(open) + if (!open) setSlashDropdownSearch("") + }, []) + // Radix composes this handler with its own content-focus via + // composeEventHandlers (default `checkForDefaultPrevented: true`), so + // calling preventDefault here skips radix's autofocus entirely. The prop + // is accepted at runtime but omitted from DropdownMenuContent's public + // TypeScript surface, so it has to be passed through an untyped spread. + const slashDropdownFocusProps = useMemo( + () => ({ + onOpenAutoFocus: (event: Event) => { + event.preventDefault() + slashDropdownInputRef.current?.focus() + }, + }), + [] + ) + const slashFilterText = useMemo(() => { + if (!slashMenuOpen || slashTriggerPos == null) return "" + const trigger = text[slashTriggerPos] + if (trigger !== "/" && trigger !== "$") return "" + const afterTrigger = text.slice(slashTriggerPos + 1) + const endIdx = afterTrigger.search(/\s/) + return endIdx === -1 ? afterTrigger : afterTrigger.slice(0, endIdx) + }, [slashMenuOpen, text, slashTriggerPos]) const filteredSlashCommands = useMemo(() => { if (!slashMenuOpen || slashCommands.length === 0 || slashTriggerPos == null) return [] if (text[slashTriggerPos] !== "/") return [] - const afterTrigger = text.slice(slashTriggerPos + 1) - const endIdx = afterTrigger.search(/\s/) - const filter = ( - endIdx === -1 ? afterTrigger : afterTrigger.slice(0, endIdx) - ).toLowerCase() + const filter = slashFilterText.toLowerCase() return slashCommands.filter((cmd) => - cmd.name.toLowerCase().startsWith(filter) + cmd.name.toLowerCase().includes(filter) ) - }, [slashMenuOpen, slashCommands, text, slashTriggerPos]) + }, [slashMenuOpen, slashCommands, text, slashTriggerPos, slashFilterText]) const filteredSlashSkills = useMemo(() => { // Skills autocomplete is Codex-only and triggered by `$`. if (agentType !== "codex") return [] @@ -566,15 +606,26 @@ export function MessageInput({ ) return [] if (text[slashTriggerPos] !== "$") return [] - const afterTrigger = text.slice(slashTriggerPos + 1) - const endIdx = afterTrigger.search(/\s/) - const filter = ( - endIdx === -1 ? afterTrigger : afterTrigger.slice(0, endIdx) - ).toLowerCase() - return nonExpertSkills.filter((skill) => - skill.id.toLowerCase().startsWith(filter) - ) - }, [slashMenuOpen, nonExpertSkills, text, agentType, slashTriggerPos]) + const filter = slashFilterText.toLowerCase() + if (!filter) return nonExpertSkills + const nameMatches: typeof nonExpertSkills = [] + const idOnlyMatches: typeof nonExpertSkills = [] + for (const skill of nonExpertSkills) { + if (skill.name.toLowerCase().includes(filter)) { + nameMatches.push(skill) + } else if (skill.id.toLowerCase().includes(filter)) { + idOnlyMatches.push(skill) + } + } + return [...nameMatches, ...idOnlyMatches] + }, [ + slashMenuOpen, + nonExpertSkills, + text, + agentType, + slashTriggerPos, + slashFilterText, + ]) const slashAutocompleteCount = filteredSlashCommands.length + filteredSlashSkills.length @@ -950,6 +1001,25 @@ export function MessageInput({ [onModeChange] ) + const handleSlashSearchChange = useCallback( + (e: React.ChangeEvent) => { + const pos = slashTriggerPosRef.current + const current = textRef.current + if (pos == null || pos < 0 || pos >= current.length) return + const trigger = current[pos] + if (trigger !== "/" && trigger !== "$") return + const afterTrigger = current.slice(pos + 1) + const endIdx = afterTrigger.search(/\s/) + const tokenEnd = endIdx === -1 ? current.length : pos + 1 + endIdx + const before = current.slice(0, pos + 1) + const rest = current.slice(tokenEnd) + const sanitized = e.target.value.replace(/\s+/g, "") + setText(before + sanitized + rest) + setSlashSelectedIndex(0) + }, + [] + ) + const handleSlashSelect = useCallback((cmd: AvailableCommandInfo) => { const pos = slashTriggerPosRef.current const current = textRef.current @@ -1046,6 +1116,49 @@ export function MessageInput({ [expertPrefix] ) + const handleSlashSearchKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const total = filteredSlashCommands.length + filteredSlashSkills.length + if (e.key === "ArrowDown") { + e.preventDefault() + if (total === 0) return + setSlashSelectedIndex((i) => (i < total - 1 ? i + 1 : 0)) + return + } + if (e.key === "ArrowUp") { + e.preventDefault() + if (total === 0) return + setSlashSelectedIndex((i) => (i > 0 ? i - 1 : total - 1)) + return + } + if (e.key === "Enter" || e.key === "Tab") { + if (total === 0) return + e.preventDefault() + if (slashSelectedIndex < filteredSlashCommands.length) { + handleSlashSelect(filteredSlashCommands[slashSelectedIndex]) + } else { + const skillIndex = slashSelectedIndex - filteredSlashCommands.length + const skill = filteredSlashSkills[skillIndex] + if (skill) handleSkillAutocompleteSelect(skill) + } + return + } + if (e.key === "Escape") { + e.preventDefault() + setSlashMenuOpen(false) + setSlashTriggerPos(null) + requestAnimationFrame(() => textareaRef.current?.focus()) + } + }, + [ + filteredSlashCommands, + filteredSlashSkills, + slashSelectedIndex, + handleSlashSelect, + handleSkillAutocompleteSelect, + ] + ) + // Experts always inject `prefix + expert-id ` at the very front of the // input, never at the cursor. The expert skill is a whole-turn directive // that the agent inspects first, so prepending keeps semantics unambiguous @@ -1719,63 +1832,77 @@ export function MessageInput({ onDrop={handleContainerDrop} > {slashMenuOpen && slashAutocompleteCount > 0 && ( -
- {filteredSlashCommands.map((cmd, i) => ( - - ))} - {filteredSlashSkills.map((skill, i) => { - const absoluteIndex = filteredSlashCommands.length + i - return ( +
+
+ + +
+
+ {filteredSlashCommands.map((cmd, i) => ( - ) - })} + ))} + {filteredSlashSkills.map((skill, i) => { + const absoluteIndex = filteredSlashCommands.length + i + return ( + + ) + })} +
)} {atMenuOpen && filteredAtFiles.length > 0 && ( @@ -1946,7 +2073,10 @@ export function MessageInput({ )} - +