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:
parent
02827e97cd
commit
1a2369df8a
|
|
@ -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 "{searchQuery}"</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user