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 <noreply@anthropic.com>
This commit is contained in:
林凯90331 2026-05-21 23:56:25 +08:00
parent 02827e97cd
commit 1a2369df8a
2 changed files with 224 additions and 42 deletions

View File

@ -1,15 +1,148 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Text, Dialog } from '@anthropic/ink'; 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 { SelectMulti } from '../../components/CustomSelect/SelectMulti.js';
import { useSearchInput } from '../../hooks/useSearchInput.js';
import { import {
listFavoriteItems, listFavoriteItems,
loadFavoriteItem, loadFavoriteItem,
unloadFavoriteItem, unloadFavoriteItem,
type FavoriteItemType,
type FavoriteItemWithStatus, type FavoriteItemWithStatus,
} from '../../costrict/favorite/favorite.js'; } from '../../costrict/favorite/favorite.js';
import { useSetAppState } from '../../state/AppState.js'; import { useSetAppState } from '../../state/AppState.js';
import { getOriginalCwd } from '../../bootstrap/state.js'; import { getOriginalCwd } from '../../bootstrap/state.js';
import type { LocalJSXCommandCall } from '../../types/command.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 && (
<Text dimColor> · :{formatScore(item.score)}</Text>
)}
</>
),
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 (
<Box marginLeft={1}>
<Text dimColor>No favorites in this category</Text>
</Box>
);
}
return (
<Box flexDirection="column" gap={1}>
{isSearchMode && (
<SearchBox
query={searchQuery}
isFocused={isSearchMode}
isTerminalFocused={isTerminalFocused}
cursorOffset={cursorOffset}
/>
)}
{filteredItems.length === 0 && searchQuery && (
<Box marginLeft={1}>
<Text dimColor>No favorites match &quot;{searchQuery}&quot;</Text>
</Box>
)}
{filteredItems.length > 0 && (
<SelectMulti
options={options}
defaultValue={defaultValue}
onChange={onChange}
onCancel={onCancel}
isDisabled={headerFocused || isSearchMode}
onUpFromFirstItem={focusHeader}
hideIndexes
/>
)}
{!isSearchMode && items.length > 0 && (
<Box marginLeft={1}>
<Text dimColor italic>type / to search, space/enter to toggle</Text>
</Box>
)}
</Box>
);
}
function CloudEnabledMenu({ function CloudEnabledMenu({
onDone, onDone,
@ -18,9 +151,26 @@ function CloudEnabledMenu({
}): React.ReactNode { }): React.ReactNode {
const setAppState = useSetAppState(); const setAppState = useSetAppState();
const [items, setItems] = useState<FavoriteItemWithStatus[] | null>(null); const [items, setItems] = useState<FavoriteItemWithStatus[] | null>(null);
const [activeTab, setActiveTab] = useState<TabId>('skill');
const previousSlugs = useRef<Set<string>>(new Set()); const previousSlugs = useRef<Set<string>>(new Set());
const isFirstChange = useRef(true); 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
listFavoriteItems() listFavoriteItems()
@ -45,23 +195,9 @@ function CloudEnabledMenu({
}; };
}, [onDone]); }, [onDone]);
const handleCancel = () => { const handleTabChange = useCallback((tabId: string) => {
onDone('Cancelled', { display: 'system' }); setActiveTab(tabId as TabId);
}; }, []);
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 handleChange = async (selectedSlugs: string[]) => { const handleChange = async (selectedSlugs: string[]) => {
if (isFirstChange.current) { if (isFirstChange.current) {
@ -81,12 +217,26 @@ function CloudEnabledMenu({
const hasMcpChanges = [...toLoad, ...toUnload].some(item => item.itemType === 'mcp'); const hasMcpChanges = [...toLoad, ...toUnload].some(item => item.itemType === 'mcp');
const hasAgentChanges = [...toLoad, ...toUnload].some(item => item.itemType === 'agent'); 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([ const results = await Promise.allSettled([
...toLoad.map(item => loadFavoriteItem(item.slug)), ...toLoad.map(item => loadFavoriteItem(item.slug)),
...toUnload.map(item => unloadFavoriteItem(item.slug)), ...toUnload.map(item => unloadFavoriteItem(item.slug)),
]); ]);
// Trigger MCP reconnection if any MCP servers were added or removed
if (hasMcpChanges) { if (hasMcpChanges) {
setAppState(prev => ({ setAppState(prev => ({
...prev, ...prev,
@ -97,7 +247,6 @@ function CloudEnabledMenu({
})); }));
} }
// Refresh agent definitions if any agents were added or removed
if (hasAgentChanges) { if (hasAgentChanges) {
const { getAgentDefinitionsWithOverrides, getActiveAgentsFromList } = await import( const { getAgentDefinitionsWithOverrides, getActiveAgentsFromList } = await import(
'@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
@ -123,35 +272,39 @@ function CloudEnabledMenu({
} }
}; };
const handleCancel = () => {
onDone('Cancelled', { display: 'system' });
};
if (items === null) { if (items === null) {
return ( return (
<Dialog <Pane color="suggestion">
title="Cloud Enabled Items" <Box marginLeft={1}>
subtitle="Toggle items to enable/disable (auto-download on enable)"
onCancel={handleCancel}
>
<Box>
<Text>Loading cloud favorites...</Text> <Text>Loading cloud favorites...</Text>
</Box> </Box>
</Dialog> </Pane>
); );
} }
return ( return (
<Dialog <Pane color="suggestion">
title="Cloud Enabled Items" <Box marginLeft={1} marginBottom={1}>
subtitle="Toggle items to enable/disable (auto-download on enable)" <Text dimColor>
onCancel={handleCancel} 访 {storeUrl}
> </Text>
<SelectMulti </Box>
key="knowledge-hub-loaded" <Tabs title="Hub" selectedTab={activeTab} onTabChange={handleTabChange} color="suggestion">
options={options} {TAB_CONFIG.map(tab => (
defaultValue={defaultValue} <Tab key={tab.id} id={tab.id} title={tab.title}>
onChange={handleChange} <TabContent
onCancel={handleCancel} items={items.filter(item => item.itemType === tab.id)}
hideIndexes onChange={handleChange}
/> onCancel={handleCancel}
</Dialog> />
</Tab>
))}
</Tabs>
</Pane>
); );
} }

View File

@ -56,6 +56,7 @@ export type FavoriteItem = {
category?: string category?: string
version?: string version?: string
favoriteCount?: number favoriteCount?: number
score?: number
favorited?: boolean favorited?: boolean
createdBy?: string createdBy?: string
createdAt?: string createdAt?: string
@ -203,6 +204,10 @@ function parseFavoriteListItem(
? data.favorited ? data.favorited
: data.favorited === 'true' || data.favorited === 1 : 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 { return {
id: String(data.id), id: String(data.id),
slug: String(data.slug ?? data.id), slug: String(data.slug ?? data.id),
@ -214,6 +219,18 @@ function parseFavoriteListItem(
version: typeof data.version === 'string' ? data.version : undefined, version: typeof data.version === 'string' ? data.version : undefined,
favoriteCount: favoriteCount:
typeof data.favoriteCount === 'number' ? data.favoriteCount : undefined, 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, favorited,
createdBy: typeof data.createdBy === 'string' ? data.createdBy : undefined, createdBy: typeof data.createdBy === 'string' ? data.createdBy : undefined,
createdAt: typeof data.createdAt === 'string' ? data.createdAt : undefined, createdAt: typeof data.createdAt === 'string' ? data.createdAt : undefined,
@ -251,6 +268,18 @@ async function getRemoteItem(id: string): Promise<FavoriteItem> {
version: typeof data.version === 'string' ? data.version : undefined, version: typeof data.version === 'string' ? data.version : undefined,
favoriteCount: favoriteCount:
typeof data.favoriteCount === 'number' ? data.favoriteCount : undefined, 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, favorited: typeof data.favorited === 'boolean' ? data.favorited : undefined,
createdBy: typeof data.createdBy === 'string' ? data.createdBy : undefined, createdBy: typeof data.createdBy === 'string' ? data.createdBy : undefined,
createdAt: typeof data.createdAt === 'string' ? data.createdAt : undefined, createdAt: typeof data.createdAt === 'string' ? data.createdAt : undefined,