diff --git a/ui/web/src/components/shared/tool-single-combobox.tsx b/ui/web/src/components/shared/tool-single-combobox.tsx new file mode 100644 index 00000000..c9d39fa8 --- /dev/null +++ b/ui/web/src/components/shared/tool-single-combobox.tsx @@ -0,0 +1,181 @@ +import { useMemo, useState, useRef, useEffect, useLayoutEffect } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { ChevronDownIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useBuiltinTools } from "@/pages/builtin-tools/hooks/use-builtin-tools"; + +interface ToolSingleComboboxProps { + value: string; + onChange: (value: string) => void; + /** Fires only when selecting from dropdown (not on typing) */ + onToolSelect?: (toolName: string) => void; + placeholder?: string; + className?: string; +} + +interface ToolOption { + name: string; + displayName: string; +} + +export function ToolSingleCombobox({ + value, + onChange, + onToolSelect, + placeholder, + className, +}: ToolSingleComboboxProps) { + const { t } = useTranslation("common"); + const { tools: builtinTools } = useBuiltinTools(); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const containerRef = useRef(null); + const inputRef = useRef(null); + const dropdownRef = useRef(null); + const [dropdownStyle, setDropdownStyle] = useState({}); + + // When value changes externally, sync the search field + const displayValue = open ? search : value; + + const allTools = useMemo(() => { + return builtinTools.map((t) => ({ + name: t.name, + displayName: t.display_name || t.name, + })); + }, [builtinTools]); + + const filtered = useMemo(() => { + const q = (open ? search : "").toLowerCase(); + return allTools.filter( + (t) => !q || t.name.toLowerCase().includes(q) || t.displayName.toLowerCase().includes(q), + ); + }, [allTools, search, open]); + + useLayoutEffect(() => { + if (!open || !containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + setDropdownStyle({ + position: "fixed", + top: rect.bottom + 4, + left: rect.left, + width: rect.width, + zIndex: 9999, + }); + }, [open]); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + const target = e.target as Node; + if ( + containerRef.current && !containerRef.current.contains(target) && + (!dropdownRef.current || !dropdownRef.current.contains(target)) + ) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + const selectTool = (name: string) => { + onChange(name); + onToolSelect?.(name); + setSearch(""); + setOpen(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + const trimmed = search.trim(); + if (trimmed) { + onChange(trimmed); + // Check if it matches a known tool for template fill + const match = allTools.find((t) => t.name === trimmed); + if (match) onToolSelect?.(trimmed); + } + setSearch(""); + setOpen(false); + } + }; + + const handleFocus = () => { + setSearch(value); + setOpen(true); + }; + + const handleBlur = () => { + // Commit typed value on blur if changed + if (search.trim() && search.trim() !== value) { + onChange(search.trim()); + } + }; + + return ( +
+
inputRef.current?.focus()} + > + { + setSearch(e.target.value); + if (!open) setOpen(true); + }} + onFocus={handleFocus} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + placeholder={placeholder ?? t("selectOrTypeTools")} + className="placeholder:text-muted-foreground min-w-[80px] flex-1 bg-transparent py-0.5 text-base md:text-sm font-mono outline-none" + /> + e.preventDefault()} + onClick={() => { + if (open) { + setOpen(false); + } else { + setSearch(value); + setOpen(true); + inputRef.current?.focus(); + } + }} + /> +
+ {open && filtered.length > 0 && createPortal( +
+
+ {t("builtinTools")} +
+ {filtered.map((tool) => ( + + ))} +
, + document.body, + )} +
+ ); +} diff --git a/ui/web/src/i18n/locales/en/hooks.json b/ui/web/src/i18n/locales/en/hooks.json index 097f1ff2..53dff00b 100644 --- a/ui/web/src/i18n/locales/en/hooks.json +++ b/ui/web/src/i18n/locales/en/hooks.json @@ -111,7 +111,9 @@ "stdout": "Stdout", "stderr": "Stderr", "statusCode": "Status code", - "updatedInput": "Updated input (diff)" + "updatedInput": "Updated input (diff)", + "toolNamePickerPlaceholder": "Select or type tool name...", + "overwriteConfirm": "Current input will be replaced with template. Continue?" }, "history": { "title": "Execution history", diff --git a/ui/web/src/i18n/locales/vi/hooks.json b/ui/web/src/i18n/locales/vi/hooks.json index 32d6cfef..15f20882 100644 --- a/ui/web/src/i18n/locales/vi/hooks.json +++ b/ui/web/src/i18n/locales/vi/hooks.json @@ -111,7 +111,9 @@ "stdout": "Stdout", "stderr": "Stderr", "statusCode": "Mã trạng thái", - "updatedInput": "Đầu vào đã cập nhật (diff)" + "updatedInput": "Đầu vào đã cập nhật (diff)", + "toolNamePickerPlaceholder": "Chọn hoặc nhập tên tool...", + "overwriteConfirm": "Nội dung hiện tại sẽ bị thay thế bằng mẫu. Tiếp tục?" }, "history": { "title": "Lịch sử thực thi", diff --git a/ui/web/src/i18n/locales/zh/hooks.json b/ui/web/src/i18n/locales/zh/hooks.json index e4f55b61..74bd209a 100644 --- a/ui/web/src/i18n/locales/zh/hooks.json +++ b/ui/web/src/i18n/locales/zh/hooks.json @@ -111,7 +111,9 @@ "stdout": "标准输出", "stderr": "标准错误", "statusCode": "状态码", - "updatedInput": "已更新输入(差异)" + "updatedInput": "已更新输入(差异)", + "toolNamePickerPlaceholder": "选择或输入工具名称...", + "overwriteConfirm": "当前内容将被模板替换。继续?" }, "history": { "title": "执行历史", diff --git a/ui/web/src/pages/hooks/components/hook-test-panel.tsx b/ui/web/src/pages/hooks/components/hook-test-panel.tsx index ffb1c756..caa0cd0d 100644 --- a/ui/web/src/pages/hooks/components/hook-test-panel.tsx +++ b/ui/web/src/pages/hooks/components/hook-test-panel.tsx @@ -4,6 +4,8 @@ import { Play, FlaskConical, AlertCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { ToolSingleCombobox } from "@/components/shared/tool-single-combobox"; +import { TOOL_INPUT_TEMPLATES } from "./tool-input-templates"; import { Textarea } from "@/components/ui/textarea"; import { Badge } from "@/components/ui/badge"; import { useTestHook, type HookConfig, type HookTestResult } from "@/hooks/use-hooks"; @@ -54,6 +56,16 @@ export function HookTestPanel({ hook }: HookTestPanelProps) { const [result, setResult] = useState(null); const [parseError, setParseError] = useState(null); + const handleToolSelect = (name: string) => { + const template = TOOL_INPUT_TEMPLATES[name]; + if (!template) return; + const json = JSON.stringify(template, null, 2); + if (toolInputRaw.trim() && toolInputRaw.trim() !== "{}") { + if (!window.confirm(t("test.overwriteConfirm"))) return; + } + setToolInputRaw(json); + }; + const handleFire = async () => { setParseError(null); let toolInput: Record; @@ -86,11 +98,11 @@ export function HookTestPanel({ hook }: HookTestPanelProps) {
- setToolName(e.target.value)} - placeholder="bash" - className="text-base md:text-sm font-mono" + onChange={setToolName} + onToolSelect={handleToolSelect} + placeholder={t("test.toolNamePickerPlaceholder")} />

{t("test.toolNameHint")}

@@ -100,7 +112,7 @@ export function HookTestPanel({ hook }: HookTestPanelProps) {