From 1a2369df8ad3ebcd7b5c5e2a97d931d3dec40b5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E5=87=AF90331?= <90331@sangfor.com> Date: Thu, 21 May 2026 23:56:25 +0800 Subject: [PATCH] feat: refactor /hub command to tabbed interface with category tabs - Replace single Dialog with Pane + Tabs (Skills/Agents/Commands/MCP) - Add search box activated by '/' key - Display score with k-formatting (e.g. 1.5k) and source label - Add cloud subscription hint with store URL at top - Add space/enter toggle hint at bottom - Support experienceScore field with fallback to score/favoriteCount - Remove All tab, default to Skills Co-Authored-By: Claude Opus 4.7 --- src/commands/knowledge-hub/cloud-enabled.tsx | 237 +++++++++++++++---- src/costrict/favorite/favorite.ts | 29 +++ 2 files changed, 224 insertions(+), 42 deletions(-) diff --git a/src/commands/knowledge-hub/cloud-enabled.tsx b/src/commands/knowledge-hub/cloud-enabled.tsx index 30333cb8f..ba235738b 100644 --- a/src/commands/knowledge-hub/cloud-enabled.tsx +++ b/src/commands/knowledge-hub/cloud-enabled.tsx @@ -1,15 +1,148 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { Box, Text, Dialog } from '@anthropic/ink'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Box, Pane, Tab, Tabs, Text, useInput, useTabHeaderFocus, useTerminalFocus } from '@anthropic/ink'; +import { SearchBox } from '../../components/SearchBox.js'; import { SelectMulti } from '../../components/CustomSelect/SelectMulti.js'; +import { useSearchInput } from '../../hooks/useSearchInput.js'; import { listFavoriteItems, loadFavoriteItem, unloadFavoriteItem, + type FavoriteItemType, type FavoriteItemWithStatus, } from '../../costrict/favorite/favorite.js'; import { useSetAppState } from '../../state/AppState.js'; import { getOriginalCwd } from '../../bootstrap/state.js'; import type { LocalJSXCommandCall } from '../../types/command.js'; +import { useKeybinding } from '../../keybindings/useKeybinding.js'; +import { getCoStrictBaseURL } from '../../costrict/provider/auth.js'; + +type TabId = FavoriteItemType; + +const TAB_CONFIG: { id: TabId; title: string }[] = [ + { id: 'skill', title: 'Skills' }, + { id: 'agent', title: 'Agents' }, + { id: 'command', title: 'Commands' }, + { id: 'mcp', title: 'MCP' }, +]; + +function formatScore(score: number | undefined): string | undefined { + if (score === undefined) return undefined; + if (score < 1000) return String(score); + const k = score / 1000; + // 如果是整数k,不显示小数;否则保留1位小数 + if (Number.isInteger(k)) return `${k}k`; + return `${k.toFixed(1)}k`; +} + +function TabContent({ + items, + onChange, + onCancel, +}: { + items: FavoriteItemWithStatus[]; + onChange: (selectedSlugs: string[]) => void; + onCancel: () => void; +}): React.ReactNode { + const { headerFocused, focusHeader } = useTabHeaderFocus(); + const isTerminalFocused = useTerminalFocus(); + const [isSearchMode, setIsSearchMode] = useState(false); + const { + query: searchQuery, + setQuery: setSearchQuery, + cursorOffset, + } = useSearchInput({ + isActive: isSearchMode, + onExit: () => setIsSearchMode(false), + onCancel: () => { + setIsSearchMode(false); + setSearchQuery(''); + }, + }); + + // Capture '/' to enter search mode + useInput( + (input, _key) => { + if (!isSearchMode && !headerFocused && input === '/') { + setIsSearchMode(true); + } + }, + { isActive: !isSearchMode && !headerFocused }, + ); + + const filteredItems = useMemo(() => { + if (!searchQuery) return items; + const lower = searchQuery.toLowerCase(); + return items.filter( + item => + item.name.toLowerCase().includes(lower) || + item.description.toLowerCase().includes(lower), + ); + }, [items, searchQuery]); + + const options = useMemo( + () => + filteredItems.map(item => ({ + label: ( + <> + {item.name} + {item.score !== undefined && ( + · 分值:{formatScore(item.score)} + )} + + ), + value: item.slug, + description: item.description || undefined, + })), + [filteredItems], + ); + + const defaultValue = useMemo( + () => filteredItems.filter(item => item.status === 'Active').map(item => item.slug), + [filteredItems], + ); + + if (items.length === 0) { + return ( + + No favorites in this category + + ); + } + + return ( + + {isSearchMode && ( + + )} + {filteredItems.length === 0 && searchQuery && ( + + No favorites match "{searchQuery}" + + )} + {filteredItems.length > 0 && ( + + )} + {!isSearchMode && items.length > 0 && ( + + type / to search, space/enter to toggle + + )} + + ); +} function CloudEnabledMenu({ onDone, @@ -18,9 +151,26 @@ function CloudEnabledMenu({ }): React.ReactNode { const setAppState = useSetAppState(); const [items, setItems] = useState(null); + const [activeTab, setActiveTab] = useState('skill'); const previousSlugs = useRef>(new Set()); const isFirstChange = useRef(true); + const storeUrl = useMemo(() => { + try { + return `${getCoStrictBaseURL()}/cloud`; + } catch { + return 'https://zgsm.sangfor.com/cloud'; + } + }, []); + + useKeybinding( + 'confirm:no', + () => { + onDone('Cancelled', { display: 'system' }); + }, + { context: 'Confirmation' }, + ); + useEffect(() => { let cancelled = false; listFavoriteItems() @@ -45,23 +195,9 @@ function CloudEnabledMenu({ }; }, [onDone]); - const handleCancel = () => { - onDone('Cancelled', { display: 'system' }); - }; - - const options = useMemo( - () => - (items ?? []).map(item => ({ - label: `[${item.itemType}] ${item.name} (${item.status})`, - value: item.slug, - })), - [items], - ); - - const defaultValue = useMemo( - () => (items ?? []).filter(item => item.status === 'Active').map(item => item.slug), - [items], - ); + const handleTabChange = useCallback((tabId: string) => { + setActiveTab(tabId as TabId); + }, []); const handleChange = async (selectedSlugs: string[]) => { if (isFirstChange.current) { @@ -81,12 +217,26 @@ function CloudEnabledMenu({ const hasMcpChanges = [...toLoad, ...toUnload].some(item => item.itemType === 'mcp'); const hasAgentChanges = [...toLoad, ...toUnload].some(item => item.itemType === 'agent'); + // Optimistically update UI state so tab switching shows correct status + setItems(prev => { + if (!prev) return prev; + const map = new Map(prev.map(i => [i.slug, i])); + for (const item of toLoad) { + const existing = map.get(item.slug); + if (existing) map.set(item.slug, { ...existing, status: 'Active' }); + } + for (const item of toUnload) { + const existing = map.get(item.slug); + if (existing) map.set(item.slug, { ...existing, status: 'Unloaded' }); + } + return Array.from(map.values()); + }); + const results = await Promise.allSettled([ ...toLoad.map(item => loadFavoriteItem(item.slug)), ...toUnload.map(item => unloadFavoriteItem(item.slug)), ]); - // Trigger MCP reconnection if any MCP servers were added or removed if (hasMcpChanges) { setAppState(prev => ({ ...prev, @@ -97,7 +247,6 @@ function CloudEnabledMenu({ })); } - // Refresh agent definitions if any agents were added or removed if (hasAgentChanges) { const { getAgentDefinitionsWithOverrides, getActiveAgentsFromList } = await import( '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' @@ -123,35 +272,39 @@ function CloudEnabledMenu({ } }; + const handleCancel = () => { + onDone('Cancelled', { display: 'system' }); + }; + if (items === null) { return ( - - + + Loading cloud favorites... - + ); } return ( - - - + + + + 云端订阅项目,如需订阅请访问 {storeUrl} + + + + {TAB_CONFIG.map(tab => ( + + item.itemType === tab.id)} + onChange={handleChange} + onCancel={handleCancel} + /> + + ))} + + ); } diff --git a/src/costrict/favorite/favorite.ts b/src/costrict/favorite/favorite.ts index 67b149b70..91350356e 100644 --- a/src/costrict/favorite/favorite.ts +++ b/src/costrict/favorite/favorite.ts @@ -56,6 +56,7 @@ export type FavoriteItem = { category?: string version?: string favoriteCount?: number + score?: number favorited?: boolean createdBy?: string createdAt?: string @@ -203,6 +204,10 @@ function parseFavoriteListItem( ? data.favorited : data.favorited === 'true' || data.favorited === 1 + logForDebugging( + `[favorite] name=${data.name} score=${data.score} experienceScore=${data.experienceScore} favoriteCount=${data.favoriteCount} createdBy=${data.createdBy} fields=[${Object.keys(data).join(',')}]`, + ) + return { id: String(data.id), slug: String(data.slug ?? data.id), @@ -214,6 +219,18 @@ function parseFavoriteListItem( version: typeof data.version === 'string' ? data.version : undefined, favoriteCount: typeof data.favoriteCount === 'number' ? data.favoriteCount : undefined, + score: + typeof data.experienceScore === 'number' + ? data.experienceScore + : typeof data.experienceScore === 'string' + ? Number(data.experienceScore) || undefined + : typeof data.score === 'number' + ? data.score + : typeof data.score === 'string' + ? Number(data.score) || undefined + : typeof data.favoriteCount === 'number' + ? data.favoriteCount + : undefined, favorited, createdBy: typeof data.createdBy === 'string' ? data.createdBy : undefined, createdAt: typeof data.createdAt === 'string' ? data.createdAt : undefined, @@ -251,6 +268,18 @@ async function getRemoteItem(id: string): Promise { version: typeof data.version === 'string' ? data.version : undefined, favoriteCount: typeof data.favoriteCount === 'number' ? data.favoriteCount : undefined, + score: + typeof data.experienceScore === 'number' + ? data.experienceScore + : typeof data.experienceScore === 'string' + ? Number(data.experienceScore) || undefined + : typeof data.score === 'number' + ? data.score + : typeof data.score === 'string' + ? Number(data.score) || undefined + : typeof data.favoriteCount === 'number' + ? data.favoriteCount + : undefined, favorited: typeof data.favorited === 'boolean' ? data.favorited : undefined, createdBy: typeof data.createdBy === 'string' ? data.createdBy : undefined, createdAt: typeof data.createdAt === 'string' ? data.createdAt : undefined,