Merge pull request #143 from y574444354/feat/hub-tabbed-ui
feat: refactor /hub command to tabbed interface
This commit is contained in:
commit
9cded24692
|
|
@ -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 "{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({
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user