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.
This commit is contained in:
xintaofei
2026-04-21 00:26:00 +08:00
parent 692b700c0d
commit 98792a696c
11 changed files with 293 additions and 84 deletions

View File

@@ -20,6 +20,7 @@ import {
GitFork, GitFork,
ListPlus, ListPlus,
Plus, Plus,
Search,
Send, Send,
Command, Command,
Sparkles, Sparkles,
@@ -543,19 +544,58 @@ export function MessageInput({
() => (availableCommands ?? []).filter((cmd) => !expertIdSet.has(cmd.name)), () => (availableCommands ?? []).filter((cmd) => !expertIdSet.has(cmd.name)),
[availableCommands, expertIdSet] [availableCommands, expertIdSet]
) )
const [slashDropdownOpen, setSlashDropdownOpen] = useState(false)
const [slashDropdownSearch, setSlashDropdownSearch] = useState("")
const slashDropdownInputRef = useRef<HTMLInputElement>(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(() => { const filteredSlashCommands = useMemo(() => {
if (!slashMenuOpen || slashCommands.length === 0 || slashTriggerPos == null) if (!slashMenuOpen || slashCommands.length === 0 || slashTriggerPos == null)
return [] return []
if (text[slashTriggerPos] !== "/") return [] if (text[slashTriggerPos] !== "/") return []
const afterTrigger = text.slice(slashTriggerPos + 1) const filter = slashFilterText.toLowerCase()
const endIdx = afterTrigger.search(/\s/)
const filter = (
endIdx === -1 ? afterTrigger : afterTrigger.slice(0, endIdx)
).toLowerCase()
return slashCommands.filter((cmd) => 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(() => { const filteredSlashSkills = useMemo(() => {
// Skills autocomplete is Codex-only and triggered by `$`. // Skills autocomplete is Codex-only and triggered by `$`.
if (agentType !== "codex") return [] if (agentType !== "codex") return []
@@ -566,15 +606,26 @@ export function MessageInput({
) )
return [] return []
if (text[slashTriggerPos] !== "$") return [] if (text[slashTriggerPos] !== "$") return []
const afterTrigger = text.slice(slashTriggerPos + 1) const filter = slashFilterText.toLowerCase()
const endIdx = afterTrigger.search(/\s/) if (!filter) return nonExpertSkills
const filter = ( const nameMatches: typeof nonExpertSkills = []
endIdx === -1 ? afterTrigger : afterTrigger.slice(0, endIdx) const idOnlyMatches: typeof nonExpertSkills = []
).toLowerCase() for (const skill of nonExpertSkills) {
return nonExpertSkills.filter((skill) => if (skill.name.toLowerCase().includes(filter)) {
skill.id.toLowerCase().startsWith(filter) nameMatches.push(skill)
) } else if (skill.id.toLowerCase().includes(filter)) {
}, [slashMenuOpen, nonExpertSkills, text, agentType, slashTriggerPos]) idOnlyMatches.push(skill)
}
}
return [...nameMatches, ...idOnlyMatches]
}, [
slashMenuOpen,
nonExpertSkills,
text,
agentType,
slashTriggerPos,
slashFilterText,
])
const slashAutocompleteCount = const slashAutocompleteCount =
filteredSlashCommands.length + filteredSlashSkills.length filteredSlashCommands.length + filteredSlashSkills.length
@@ -950,6 +1001,25 @@ export function MessageInput({
[onModeChange] [onModeChange]
) )
const handleSlashSearchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
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 handleSlashSelect = useCallback((cmd: AvailableCommandInfo) => {
const pos = slashTriggerPosRef.current const pos = slashTriggerPosRef.current
const current = textRef.current const current = textRef.current
@@ -1046,6 +1116,49 @@ export function MessageInput({
[expertPrefix] [expertPrefix]
) )
const handleSlashSearchKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
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 // 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 // input, never at the cursor. The expert skill is a whole-turn directive
// that the agent inspects first, so prepending keeps semantics unambiguous // that the agent inspects first, so prepending keeps semantics unambiguous
@@ -1719,10 +1832,23 @@ export function MessageInput({
onDrop={handleContainerDrop} onDrop={handleContainerDrop}
> >
{slashMenuOpen && slashAutocompleteCount > 0 && ( {slashMenuOpen && slashAutocompleteCount > 0 && (
<div <div className="absolute bottom-full left-0 right-0 mb-1 z-50 flex max-h-[min(16rem,40dvh)] flex-col overflow-hidden rounded-xl border border-border bg-popover shadow-lg">
ref={slashMenuListRef} <div className="flex shrink-0 items-center gap-2 border-b border-border/60 px-3 py-2">
className="absolute bottom-full left-0 right-0 mb-1 z-50 max-h-[min(16rem,40dvh)] overflow-y-auto rounded-xl border border-border bg-popover p-1 shadow-lg" <Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
> <input
type="text"
role="searchbox"
aria-label={t("slashSearchPlaceholder")}
value={slashFilterText}
onChange={handleSlashSearchChange}
onKeyDown={handleSlashSearchKeyDown}
placeholder={t("slashSearchPlaceholder")}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
autoComplete="off"
spellCheck={false}
/>
</div>
<div ref={slashMenuListRef} className="flex-1 overflow-y-auto p-1">
{filteredSlashCommands.map((cmd, i) => ( {filteredSlashCommands.map((cmd, i) => (
<button <button
key={`cmd-${cmd.name}`} key={`cmd-${cmd.name}`}
@@ -1777,6 +1903,7 @@ export function MessageInput({
) )
})} })}
</div> </div>
</div>
)} )}
{atMenuOpen && filteredAtFiles.length > 0 && ( {atMenuOpen && filteredAtFiles.length > 0 && (
<FileMentionMenu <FileMentionMenu
@@ -1946,7 +2073,10 @@ export function MessageInput({
)} )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<DropdownMenu> <DropdownMenu
open={slashDropdownOpen}
onOpenChange={handleSlashDropdownOpenChange}
>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
disabled={disabled || slashCommands.length === 0} disabled={disabled || slashCommands.length === 0}
@@ -1965,23 +2095,82 @@ export function MessageInput({
<DropdownMenuContent <DropdownMenuContent
side="top" side="top"
align="start" align="start"
className="min-w-72 overflow-y-auto" className="flex min-w-72 flex-col overflow-hidden p-0"
style={{ style={{
maxHeight: maxHeight:
"min(32rem, var(--radix-dropdown-menu-content-available-height))", "min(32rem, var(--radix-dropdown-menu-content-available-height))",
}} }}
{...slashDropdownFocusProps}
> >
{slashCommands.map((cmd) => ( <div className="flex shrink-0 items-center gap-2 border-b border-border/60 px-3 py-2">
<Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<input
ref={slashDropdownInputRef}
type="text"
role="searchbox"
aria-label={t("slashSearchPlaceholder")}
value={slashDropdownSearch}
onChange={(e) => setSlashDropdownSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault()
const container = e.currentTarget.closest(
'[data-slot="dropdown-menu-content"]'
)
const firstItem =
container?.querySelector<HTMLElement>(
'[role="menuitem"]'
)
firstItem?.focus()
return
}
if (e.key === "Enter") {
e.preventDefault()
const first = filteredSlashDropdownCommands[0]
if (first) {
handleSlashPopoverSelect(first)
setSlashDropdownOpen(false)
}
return
}
if (e.key === "Escape" || e.key === "Tab") return
// Prevent radix DropdownMenu's built-in typeahead from
// hijacking letter keys while the user is typing.
e.stopPropagation()
}}
placeholder={t("slashSearchPlaceholder")}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
autoComplete="off"
spellCheck={false}
/>
</div>
<div className="flex-1 overflow-y-auto p-1">
{filteredSlashDropdownCommands.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
{t("slashSearchEmpty")}
</div>
) : (
filteredSlashDropdownCommands.map((cmd) => (
<DropdownMenuItem <DropdownMenuItem
key={cmd.name} key={cmd.name}
onClick={() => handleSlashPopoverSelect(cmd)} onClick={() => handleSlashPopoverSelect(cmd)}
// Radix focuses the item on pointermove, which fires
// while scrolling (items slide under the cursor) and
// steals focus from the search input. Short-circuit
// that default with preventDefault so the search
// keeps focus until the user explicitly clicks.
onPointerMove={(e) => e.preventDefault()}
onPointerLeave={(e) => e.preventDefault()}
className="hover:bg-accent hover:text-accent-foreground"
> >
<DropdownRadioItemContent <DropdownRadioItemContent
label={`/${cmd.name}`} label={`/${cmd.name}`}
description={cmd.description} description={cmd.description}
/> />
</DropdownMenuItem> </DropdownMenuItem>
))} ))
)}
</div>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{/* 宽屏内联显示,窄屏(<480px通过"更多"气泡显示 */} {/* 宽屏内联显示,窄屏(<480px通过"更多"气泡显示 */}

View File

@@ -1573,7 +1573,9 @@
"forkAndSend": "تفريع وإرسال", "forkAndSend": "تفريع وإرسال",
"slashCommands": "أوامر الشرطة المائلة", "slashCommands": "أوامر الشرطة المائلة",
"expertSkills": "مهارات الخبراء", "expertSkills": "مهارات الخبراء",
"expertsEmptyForAgent": "لا يحتوي هذا العميل على خبراء مفعّلين. فعّلهم من الإعدادات > الخبراء." "expertsEmptyForAgent": "لا يحتوي هذا العميل على خبراء مفعّلين. فعّلهم من الإعدادات > الخبراء.",
"slashSearchPlaceholder": "البحث عن الأوامر...",
"slashSearchEmpty": "لا توجد أوامر مطابقة"
}, },
"messageQueue": { "messageQueue": {
"addToQueue": "إضافة للقائمة", "addToQueue": "إضافة للقائمة",

View File

@@ -1573,7 +1573,9 @@
"forkAndSend": "Fork & Senden", "forkAndSend": "Fork & Senden",
"slashCommands": "Slash-Befehle", "slashCommands": "Slash-Befehle",
"expertSkills": "Expertenfähigkeiten", "expertSkills": "Expertenfähigkeiten",
"expertsEmptyForAgent": "Dieser Agent hat keine aktivierten Experten. Aktivieren Sie sie unter Einstellungen > Experten." "expertsEmptyForAgent": "Dieser Agent hat keine aktivierten Experten. Aktivieren Sie sie unter Einstellungen > Experten.",
"slashSearchPlaceholder": "Befehle suchen...",
"slashSearchEmpty": "Keine passenden Befehle"
}, },
"messageQueue": { "messageQueue": {
"addToQueue": "Zur Warteschlange", "addToQueue": "Zur Warteschlange",

View File

@@ -1573,7 +1573,9 @@
"forkAndSend": "Fork & Send", "forkAndSend": "Fork & Send",
"slashCommands": "Slash commands", "slashCommands": "Slash commands",
"expertSkills": "Expert skills", "expertSkills": "Expert skills",
"expertsEmptyForAgent": "This agent has no enabled experts. Enable them in Settings > Experts." "expertsEmptyForAgent": "This agent has no enabled experts. Enable them in Settings > Experts.",
"slashSearchPlaceholder": "Search commands...",
"slashSearchEmpty": "No matching commands"
}, },
"messageQueue": { "messageQueue": {
"addToQueue": "Queue message", "addToQueue": "Queue message",

View File

@@ -1573,7 +1573,9 @@
"forkAndSend": "Fork y Enviar", "forkAndSend": "Fork y Enviar",
"slashCommands": "Comandos de barra", "slashCommands": "Comandos de barra",
"expertSkills": "Habilidades de expertos", "expertSkills": "Habilidades de expertos",
"expertsEmptyForAgent": "Este agente no tiene expertos habilitados. Actívalos en Configuración > Expertos." "expertsEmptyForAgent": "Este agente no tiene expertos habilitados. Actívalos en Configuración > Expertos.",
"slashSearchPlaceholder": "Buscar comandos...",
"slashSearchEmpty": "Sin comandos coincidentes"
}, },
"messageQueue": { "messageQueue": {
"addToQueue": "Agregar a la cola", "addToQueue": "Agregar a la cola",

View File

@@ -1573,7 +1573,9 @@
"forkAndSend": "Fork & Envoyer", "forkAndSend": "Fork & Envoyer",
"slashCommands": "Commandes slash", "slashCommands": "Commandes slash",
"expertSkills": "Compétences d'expert", "expertSkills": "Compétences d'expert",
"expertsEmptyForAgent": "Cet agent n'a aucun expert activé. Activez-les dans Paramètres > Experts." "expertsEmptyForAgent": "Cet agent n'a aucun expert activé. Activez-les dans Paramètres > Experts.",
"slashSearchPlaceholder": "Rechercher des commandes...",
"slashSearchEmpty": "Aucune commande correspondante"
}, },
"messageQueue": { "messageQueue": {
"addToQueue": "Mettre en file", "addToQueue": "Mettre en file",

View File

@@ -1573,7 +1573,9 @@
"forkAndSend": "フォークして送信", "forkAndSend": "フォークして送信",
"slashCommands": "スラッシュコマンド", "slashCommands": "スラッシュコマンド",
"expertSkills": "エキスパートスキル", "expertSkills": "エキスパートスキル",
"expertsEmptyForAgent": "このエージェントで有効なエキスパートはありません。「設定 > エキスパート」から有効にしてください。" "expertsEmptyForAgent": "このエージェントで有効なエキスパートはありません。「設定 > エキスパート」から有効にしてください。",
"slashSearchPlaceholder": "コマンドを検索...",
"slashSearchEmpty": "一致するコマンドがありません"
}, },
"messageQueue": { "messageQueue": {
"addToQueue": "キューに追加", "addToQueue": "キューに追加",

View File

@@ -1573,7 +1573,9 @@
"forkAndSend": "포크 & 전송", "forkAndSend": "포크 & 전송",
"slashCommands": "슬래시 명령", "slashCommands": "슬래시 명령",
"expertSkills": "전문가 스킬", "expertSkills": "전문가 스킬",
"expertsEmptyForAgent": "이 에이전트에 활성화된 전문가가 없습니다. 설정 > 전문가에서 활성화하세요." "expertsEmptyForAgent": "이 에이전트에 활성화된 전문가가 없습니다. 설정 > 전문가에서 활성화하세요.",
"slashSearchPlaceholder": "명령 검색...",
"slashSearchEmpty": "일치하는 명령이 없습니다"
}, },
"messageQueue": { "messageQueue": {
"addToQueue": "대기열에 추가", "addToQueue": "대기열에 추가",

View File

@@ -1573,7 +1573,9 @@
"forkAndSend": "Fork & Enviar", "forkAndSend": "Fork & Enviar",
"slashCommands": "Comandos de barra", "slashCommands": "Comandos de barra",
"expertSkills": "Habilidades de especialistas", "expertSkills": "Habilidades de especialistas",
"expertsEmptyForAgent": "Este agente não tem especialistas ativados. Ative-os em Configurações > Especialistas." "expertsEmptyForAgent": "Este agente não tem especialistas ativados. Ative-os em Configurações > Especialistas.",
"slashSearchPlaceholder": "Buscar comandos...",
"slashSearchEmpty": "Nenhum comando correspondente"
}, },
"messageQueue": { "messageQueue": {
"addToQueue": "Adicionar à fila", "addToQueue": "Adicionar à fila",

View File

@@ -1573,7 +1573,9 @@
"forkAndSend": "分叉发送", "forkAndSend": "分叉发送",
"slashCommands": "斜杠命令", "slashCommands": "斜杠命令",
"expertSkills": "专家技能", "expertSkills": "专家技能",
"expertsEmptyForAgent": "该智能体没有启用任何专家。请在「设置 > 专家」中启用。" "expertsEmptyForAgent": "该智能体没有启用任何专家。请在「设置 > 专家」中启用。",
"slashSearchPlaceholder": "搜索命令...",
"slashSearchEmpty": "没有匹配的命令"
}, },
"messageQueue": { "messageQueue": {
"addToQueue": "加入队列", "addToQueue": "加入队列",

View File

@@ -1573,7 +1573,9 @@
"forkAndSend": "分叉發送", "forkAndSend": "分叉發送",
"slashCommands": "斜線命令", "slashCommands": "斜線命令",
"expertSkills": "專家技能", "expertSkills": "專家技能",
"expertsEmptyForAgent": "該智慧代理沒有啟用任何專家。請在「設定 > 專家」中啟用。" "expertsEmptyForAgent": "該智慧代理沒有啟用任何專家。請在「設定 > 專家」中啟用。",
"slashSearchPlaceholder": "搜尋命令...",
"slashSearchEmpty": "沒有符合的指令"
}, },
"messageQueue": { "messageQueue": {
"addToQueue": "加入佇列", "addToQueue": "加入佇列",