Merge pull request #143 from y574444354/feat/hub-tabbed-ui

feat: refactor /hub command to tabbed interface
This commit is contained in:
linkai0924 2026-05-21 23:59:50 +08:00 committed by GitHub
commit 9cded24692
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 224 additions and 42 deletions

View File

@ -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 && (
<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({
onDone,
@ -18,9 +151,26 @@ function CloudEnabledMenu({
}): React.ReactNode {
const setAppState = useSetAppState();
const [items, setItems] = useState<FavoriteItemWithStatus[] | null>(null);
const [activeTab, setActiveTab] = useState<TabId>('skill');
const previousSlugs = useRef<Set<string>>(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 (
<Dialog
title="Cloud Enabled Items"
subtitle="Toggle items to enable/disable (auto-download on enable)"
onCancel={handleCancel}
>
<Box>
<Pane color="suggestion">
<Box marginLeft={1}>
<Text>Loading cloud favorites...</Text>
</Box>
</Dialog>
</Pane>
);
}
return (
<Dialog
title="Cloud Enabled Items"
subtitle="Toggle items to enable/disable (auto-download on enable)"
onCancel={handleCancel}
>
<SelectMulti
key="knowledge-hub-loaded"
options={options}
defaultValue={defaultValue}
onChange={handleChange}
onCancel={handleCancel}
hideIndexes
/>
</Dialog>
<Pane color="suggestion">
<Box marginLeft={1} marginBottom={1}>
<Text dimColor>
访 {storeUrl}
</Text>
</Box>
<Tabs title="Hub" selectedTab={activeTab} onTabChange={handleTabChange} color="suggestion">
{TAB_CONFIG.map(tab => (
<Tab key={tab.id} id={tab.id} title={tab.title}>
<TabContent
items={items.filter(item => item.itemType === tab.id)}
onChange={handleChange}
onCancel={handleCancel}
/>
</Tab>
))}
</Tabs>
</Pane>
);
}

View File

@ -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<FavoriteItem> {
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,