diff --git a/ccw/frontend/src/components/codexlens/AdvancedTab.tsx b/ccw/frontend/src/components/codexlens/AdvancedTab.tsx index 73b30882..06274da7 100644 --- a/ccw/frontend/src/components/codexlens/AdvancedTab.tsx +++ b/ccw/frontend/src/components/codexlens/AdvancedTab.tsx @@ -14,6 +14,7 @@ import { Badge } from '@/components/ui/Badge'; import { useCodexLensEnv, useUpdateCodexLensEnv } from '@/hooks'; import { useNotifications } from '@/hooks'; import { cn } from '@/lib/utils'; +import { CcwToolsCard } from './CcwToolsCard'; interface AdvancedTabProps { enabled?: boolean; @@ -238,6 +239,9 @@ export function AdvancedTab({ enabled = true }: AdvancedTabProps) { )} + {/* CCW Tools Card */} + + {/* Environment Variables Editor */}
diff --git a/ccw/frontend/src/components/codexlens/CcwToolsCard.tsx b/ccw/frontend/src/components/codexlens/CcwToolsCard.tsx new file mode 100644 index 00000000..037d238e --- /dev/null +++ b/ccw/frontend/src/components/codexlens/CcwToolsCard.tsx @@ -0,0 +1,153 @@ +// ======================================== +// CCW Tools Card Component +// ======================================== +// Displays all registered CCW tools, highlighting codex-lens related tools + +import { useIntl } from 'react-intl'; +import { Wrench, AlertCircle, Loader2 } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; +import { Badge } from '@/components/ui/Badge'; +import { useCcwToolsList } from '@/hooks'; +import type { CcwToolInfo } from '@/lib/api'; + +const CODEX_LENS_PREFIX = 'codex_lens'; + +function isCodexLensTool(tool: CcwToolInfo): boolean { + return tool.name.startsWith(CODEX_LENS_PREFIX); +} + +export function CcwToolsCard() { + const { formatMessage } = useIntl(); + const { tools, isLoading, error } = useCcwToolsList(); + + const codexLensTools = tools.filter(isCodexLensTool); + const otherTools = tools.filter((t) => !isCodexLensTool(t)); + + if (isLoading) { + return ( + + + + + {formatMessage({ id: 'codexlens.mcp.title' })} + + + +
+ + {formatMessage({ id: 'codexlens.mcp.loading' })} +
+
+
+ ); + } + + if (error) { + return ( + + + + + {formatMessage({ id: 'codexlens.mcp.title' })} + + + +
+ +
+

+ {formatMessage({ id: 'codexlens.mcp.error' })} +

+

+ {formatMessage({ id: 'codexlens.mcp.errorDesc' })} +

+
+
+
+
+ ); + } + + if (tools.length === 0) { + return ( + + + + + {formatMessage({ id: 'codexlens.mcp.title' })} + + + +

+ {formatMessage({ id: 'codexlens.mcp.emptyDesc' })} +

+
+
+ ); + } + + return ( + + + +
+ + {formatMessage({ id: 'codexlens.mcp.title' })} +
+ + {formatMessage({ id: 'codexlens.mcp.totalCount' }, { count: tools.length })} + +
+
+ + {/* CodexLens Tools Section */} + {codexLensTools.length > 0 && ( +
+

+ {formatMessage({ id: 'codexlens.mcp.codexLensSection' })} +

+
+ {codexLensTools.map((tool) => ( + + ))} +
+
+ )} + + {/* Other Tools Section */} + {otherTools.length > 0 && ( +
+

+ {formatMessage({ id: 'codexlens.mcp.otherSection' })} +

+
+ {otherTools.map((tool) => ( + + ))} +
+
+ )} +
+
+ ); +} + +interface ToolRowProps { + tool: CcwToolInfo; + variant: 'default' | 'secondary'; +} + +function ToolRow({ tool, variant }: ToolRowProps) { + return ( +
+ + {tool.name} + + + {tool.description} + +
+ ); +} + +export default CcwToolsCard; diff --git a/ccw/frontend/src/components/codexlens/LspServerCard.tsx b/ccw/frontend/src/components/codexlens/LspServerCard.tsx new file mode 100644 index 00000000..de7fb917 --- /dev/null +++ b/ccw/frontend/src/components/codexlens/LspServerCard.tsx @@ -0,0 +1,157 @@ +// ======================================== +// CodexLens LSP Server Card +// ======================================== +// Displays LSP server status, stats, and start/stop/restart controls + +import { useIntl } from 'react-intl'; +import { + Server, + Power, + PowerOff, + RotateCw, + FolderOpen, + Layers, + Cpu, +} from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; +import { Badge } from '@/components/ui/Badge'; +import { Button } from '@/components/ui/Button'; +import { cn } from '@/lib/utils'; +import { useCodexLensLspStatus, useCodexLensLspMutations } from '@/hooks'; +import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; + +interface LspServerCardProps { + disabled?: boolean; +} + +export function LspServerCard({ disabled = false }: LspServerCardProps) { + const { formatMessage } = useIntl(); + const projectPath = useWorkflowStore(selectProjectPath); + const { + available, + semanticAvailable, + projectCount, + modes, + isLoading, + } = useCodexLensLspStatus(); + const { startLsp, stopLsp, restartLsp, isStarting, isStopping, isRestarting } = useCodexLensLspMutations(); + + const isMutating = isStarting || isStopping || isRestarting; + + const handleToggle = async () => { + if (available) { + await stopLsp(projectPath); + } else { + await startLsp(projectPath); + } + }; + + const handleRestart = async () => { + await restartLsp(projectPath); + }; + + return ( + + + +
+ + {formatMessage({ id: 'codexlens.lsp.title' })} +
+ + {available + ? formatMessage({ id: 'codexlens.lsp.status.running' }) + : formatMessage({ id: 'codexlens.lsp.status.stopped' }) + } + +
+
+ + {/* Stats Grid */} +
+
+ +
+

+ {formatMessage({ id: 'codexlens.lsp.projects' })} +

+

+ {available ? projectCount : '--'} +

+
+
+
+ +
+

+ {formatMessage({ id: 'codexlens.lsp.semanticAvailable' })} +

+

+ {semanticAvailable + ? formatMessage({ id: 'codexlens.lsp.available' }) + : formatMessage({ id: 'codexlens.lsp.unavailable' }) + } +

+
+
+
+ 0 ? 'text-accent' : 'text-muted-foreground')} /> +
+

+ {formatMessage({ id: 'codexlens.lsp.modes' })} +

+

+ {available ? modes.length : '--'} +

+
+
+
+ + {/* Action Buttons */} +
+ + {available && ( + + )} +
+
+
+ ); +} + +export default LspServerCard; diff --git a/ccw/frontend/src/components/codexlens/OverviewTab.tsx b/ccw/frontend/src/components/codexlens/OverviewTab.tsx index 7014ad84..070fc489 100644 --- a/ccw/frontend/src/components/codexlens/OverviewTab.tsx +++ b/ccw/frontend/src/components/codexlens/OverviewTab.tsx @@ -16,6 +16,7 @@ import { cn } from '@/lib/utils'; import type { CodexLensVenvStatus, CodexLensConfig } from '@/lib/api'; import { IndexOperations } from './IndexOperations'; import { FileWatcherCard } from './FileWatcherCard'; +import { LspServerCard } from './LspServerCard'; interface OverviewTabProps { installed: boolean; @@ -143,8 +144,11 @@ export function OverviewTab({ installed, status, config, isLoading, onRefresh }:
- {/* File Watcher */} - + {/* Service Management */} +
+ + +
{/* Index Operations */} diff --git a/ccw/frontend/src/components/codexlens/SettingsTab.test.tsx b/ccw/frontend/src/components/codexlens/SettingsTab.test.tsx index f6e735b6..bb91e551 100644 --- a/ccw/frontend/src/components/codexlens/SettingsTab.test.tsx +++ b/ccw/frontend/src/components/codexlens/SettingsTab.test.tsx @@ -18,6 +18,8 @@ vi.mock('@/hooks', async (importOriginal) => { useCodexLensEnv: vi.fn(), useUpdateCodexLensEnv: vi.fn(), useCodexLensModels: vi.fn(), + useCodexLensRerankerConfig: vi.fn(), + useUpdateRerankerConfig: vi.fn(), useNotifications: vi.fn(() => ({ toasts: [], wsStatus: 'disconnected' as const, @@ -48,6 +50,8 @@ import { useCodexLensEnv, useUpdateCodexLensEnv, useCodexLensModels, + useCodexLensRerankerConfig, + useUpdateRerankerConfig, useNotifications, } from '@/hooks'; @@ -102,6 +106,25 @@ function setupDefaultMocks() { error: null, refetch: vi.fn(), }); + vi.mocked(useCodexLensRerankerConfig).mockReturnValue({ + data: undefined, + backend: 'fastembed', + modelName: '', + apiProvider: '', + apiKeySet: false, + availableBackends: ['onnx', 'api', 'litellm', 'legacy'], + apiProviders: ['siliconflow', 'cohere', 'jina'], + litellmModels: undefined, + configSource: 'default', + isLoading: false, + error: null, + refetch: vi.fn(), + }); + vi.mocked(useUpdateRerankerConfig).mockReturnValue({ + updateConfig: vi.fn().mockResolvedValue({ success: true, message: 'Saved' }), + isUpdating: false, + error: null, + }); } describe('SettingsTab', () => { @@ -324,6 +347,25 @@ describe('SettingsTab', () => { error: null, refetch: vi.fn(), }); + vi.mocked(useCodexLensRerankerConfig).mockReturnValue({ + data: undefined, + backend: 'fastembed', + modelName: '', + apiProvider: '', + apiKeySet: false, + availableBackends: [], + apiProviders: [], + litellmModels: undefined, + configSource: 'default', + isLoading: false, + error: null, + refetch: vi.fn(), + }); + vi.mocked(useUpdateRerankerConfig).mockReturnValue({ + updateConfig: vi.fn().mockResolvedValue({ success: true }), + isUpdating: false, + error: null, + }); render(); diff --git a/ccw/frontend/src/components/codexlens/SettingsTab.tsx b/ccw/frontend/src/components/codexlens/SettingsTab.tsx index 75299646..13f3d8d6 100644 --- a/ccw/frontend/src/components/codexlens/SettingsTab.tsx +++ b/ccw/frontend/src/components/codexlens/SettingsTab.tsx @@ -7,22 +7,265 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import { useIntl } from 'react-intl'; -import { Save, RefreshCw } from 'lucide-react'; +import { Save, RefreshCw, Loader2 } from 'lucide-react'; import { Card } from '@/components/ui/Card'; import { Input } from '@/components/ui/Input'; import { Button } from '@/components/ui/Button'; import { Label } from '@/components/ui/Label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + SelectGroup, + SelectLabel, +} from '@/components/ui/Select'; import { useCodexLensConfig, useCodexLensEnv, useUpdateCodexLensEnv, useCodexLensModels, + useCodexLensRerankerConfig, + useUpdateRerankerConfig, } from '@/hooks'; import { useNotifications } from '@/hooks'; import { cn } from '@/lib/utils'; import { SchemaFormRenderer } from './SchemaFormRenderer'; import { envVarGroupsSchema, getSchemaDefaults } from './envVarSchema'; +// ========== Reranker Configuration Card ========== + +interface RerankerConfigCardProps { + enabled?: boolean; +} + +function RerankerConfigCard({ enabled = true }: RerankerConfigCardProps) { + const { formatMessage } = useIntl(); + const { success: showSuccess, error: showError } = useNotifications(); + + const { + backend: serverBackend, + modelName: serverModelName, + apiProvider: serverApiProvider, + apiKeySet, + availableBackends, + apiProviders, + litellmModels, + configSource, + isLoading, + } = useCodexLensRerankerConfig({ enabled }); + + const { updateConfig, isUpdating } = useUpdateRerankerConfig(); + + const [backend, setBackend] = useState(''); + const [modelName, setModelName] = useState(''); + const [apiProvider, setApiProvider] = useState(''); + const [hasChanges, setHasChanges] = useState(false); + + // Initialize form from server data + useEffect(() => { + setBackend(serverBackend); + setModelName(serverModelName); + setApiProvider(serverApiProvider); + setHasChanges(false); + }, [serverBackend, serverModelName, serverApiProvider]); + + // Detect changes + useEffect(() => { + const changed = + backend !== serverBackend || + modelName !== serverModelName || + apiProvider !== serverApiProvider; + setHasChanges(changed); + }, [backend, modelName, apiProvider, serverBackend, serverModelName, serverApiProvider]); + + const handleSave = async () => { + try { + const request: Record = {}; + if (backend !== serverBackend) request.backend = backend; + if (modelName !== serverModelName) { + // When backend is litellm, model_name is sent as litellm_endpoint + if (backend === 'litellm') { + request.litellm_endpoint = modelName; + } else { + request.model_name = modelName; + } + } + if (apiProvider !== serverApiProvider) request.api_provider = apiProvider; + + const result = await updateConfig(request); + if (result.success) { + showSuccess( + formatMessage({ id: 'codexlens.reranker.saveSuccess' }), + result.message || '' + ); + } else { + showError( + formatMessage({ id: 'codexlens.reranker.saveFailed' }), + result.error || '' + ); + } + } catch (err) { + showError( + formatMessage({ id: 'codexlens.reranker.saveFailed' }), + err instanceof Error ? err.message : '' + ); + } + }; + + // Determine whether to show litellm model dropdown or text input + const showLitellmModelSelect = backend === 'litellm' && litellmModels && litellmModels.length > 0; + // Show provider dropdown only for api backend + const showProviderSelect = backend === 'api'; + + if (isLoading) { + return ( + +
+ + {formatMessage({ id: 'common.loading' })} +
+
+ ); + } + + return ( + +

+ {formatMessage({ id: 'codexlens.reranker.title' })} +

+

+ {formatMessage({ id: 'codexlens.reranker.description' })} +

+ +
+ {/* Backend Select */} +
+ + +

+ {formatMessage({ id: 'codexlens.reranker.backendHint' })} +

+
+ + {/* Model - Select for litellm, Input for others */} +
+ + {showLitellmModelSelect ? ( + + ) : ( + setModelName(e.target.value)} + placeholder={formatMessage({ id: 'codexlens.reranker.selectModel' })} + /> + )} +

+ {formatMessage({ id: 'codexlens.reranker.modelHint' })} +

+
+ + {/* Provider Select (only for api backend) */} + {showProviderSelect && ( +
+ + +

+ {formatMessage({ id: 'codexlens.reranker.providerHint' })} +

+
+ )} + + {/* Status Row */} +
+ + {formatMessage({ id: 'codexlens.reranker.apiKeyStatus' })}:{' '} + + {apiKeySet + ? formatMessage({ id: 'codexlens.reranker.apiKeySet' }) + : formatMessage({ id: 'codexlens.reranker.apiKeyNotSet' })} + + + + {formatMessage({ id: 'codexlens.reranker.configSource' })}: {configSource} + +
+ + {/* Save Button */} +
+ +
+
+
+ ); +} + +// ========== Settings Tab ========== + interface SettingsTabProps { enabled?: boolean; } @@ -219,6 +462,9 @@ export function SettingsTab({ enabled = true }: SettingsTabProps) {
+ {/* Reranker Configuration */} + + {/* General Configuration */}

diff --git a/ccw/frontend/src/hooks/index.ts b/ccw/frontend/src/hooks/index.ts index 5236b44a..0031292e 100644 --- a/ccw/frontend/src/hooks/index.ts +++ b/ccw/frontend/src/hooks/index.ts @@ -286,6 +286,11 @@ export { useCancelIndexing, useCodexLensWatcher, useCodexLensWatcherMutations, + useCodexLensLspStatus, + useCodexLensLspMutations, + useCodexLensRerankerConfig, + useUpdateRerankerConfig, + useCcwToolsList, } from './useCodexLens'; export type { UseCodexLensDashboardOptions, @@ -323,4 +328,11 @@ export type { UseCodexLensWatcherOptions, UseCodexLensWatcherReturn, UseCodexLensWatcherMutationsReturn, + UseCodexLensLspStatusOptions, + UseCodexLensLspStatusReturn, + UseCodexLensLspMutationsReturn, + UseCodexLensRerankerConfigOptions, + UseCodexLensRerankerConfigReturn, + UseUpdateRerankerConfigReturn, + UseCcwToolsListReturn, } from './useCodexLens'; diff --git a/ccw/frontend/src/hooks/useCodexLens.ts b/ccw/frontend/src/hooks/useCodexLens.ts index 064aee41..5521ff7e 100644 --- a/ccw/frontend/src/hooks/useCodexLens.ts +++ b/ccw/frontend/src/hooks/useCodexLens.ts @@ -64,8 +64,18 @@ import { type CodexLensLspStatusResponse, type CodexLensSemanticSearchParams, type CodexLensSemanticSearchResponse, + type RerankerConfigResponse, + type RerankerConfigUpdateRequest, + type RerankerConfigUpdateResponse, fetchCodexLensLspStatus, + startCodexLensLsp, + stopCodexLensLsp, + restartCodexLensLsp, semanticSearchCodexLens, + fetchRerankerConfig, + updateRerankerConfig, + fetchCcwTools, + type CcwToolInfo, } from '../lib/api'; import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; @@ -91,6 +101,8 @@ export const codexLensKeys = { lspStatus: () => [...codexLensKeys.all, 'lspStatus'] as const, semanticSearch: (params: CodexLensSemanticSearchParams) => [...codexLensKeys.all, 'semanticSearch', params] as const, watcher: () => [...codexLensKeys.all, 'watcher'] as const, + rerankerConfig: () => [...codexLensKeys.all, 'rerankerConfig'] as const, + ccwTools: () => [...codexLensKeys.all, 'ccwTools'] as const, }; // Default stale times @@ -1384,6 +1396,8 @@ export interface UseCodexLensLspStatusReturn { available: boolean; semanticAvailable: boolean; vectorIndex: boolean; + projectCount: number; + embeddings: Record | undefined; modes: string[]; strategies: string[]; isLoading: boolean; @@ -1393,6 +1407,7 @@ export interface UseCodexLensLspStatusReturn { /** * Hook for checking CodexLens LSP/semantic search availability + * Polls every 5 seconds when the LSP server is available */ export function useCodexLensLspStatus(options: UseCodexLensLspStatusOptions = {}): UseCodexLensLspStatusReturn { const { enabled = true, staleTime = STALE_TIME_MEDIUM } = options; @@ -1402,6 +1417,10 @@ export function useCodexLensLspStatus(options: UseCodexLensLspStatusOptions = {} queryFn: fetchCodexLensLspStatus, staleTime, enabled, + refetchInterval: (query) => { + const data = query.state.data as CodexLensLspStatusResponse | undefined; + return data?.available ? 5000 : false; + }, retry: 2, }); @@ -1414,6 +1433,8 @@ export function useCodexLensLspStatus(options: UseCodexLensLspStatusOptions = {} available: query.data?.available ?? false, semanticAvailable: query.data?.semantic_available ?? false, vectorIndex: query.data?.vector_index ?? false, + projectCount: query.data?.project_count ?? 0, + embeddings: query.data?.embeddings, modes: query.data?.modes ?? [], strategies: query.data?.strategies ?? [], isLoading: query.isLoading, @@ -1422,6 +1443,84 @@ export function useCodexLensLspStatus(options: UseCodexLensLspStatusOptions = {} }; } +export interface UseCodexLensLspMutationsReturn { + startLsp: (path?: string) => Promise<{ success: boolean; message?: string; error?: string }>; + stopLsp: (path?: string) => Promise<{ success: boolean; message?: string; error?: string }>; + restartLsp: (path?: string) => Promise<{ success: boolean; message?: string; error?: string }>; + isStarting: boolean; + isStopping: boolean; + isRestarting: boolean; +} + +/** + * Hook for LSP server start/stop/restart mutations + */ +export function useCodexLensLspMutations(): UseCodexLensLspMutationsReturn { + const queryClient = useQueryClient(); + const formatMessage = useFormatMessage(); + const { success, error: errorToast } = useNotifications(); + + const startMutation = useMutation({ + mutationFn: ({ path }: { path?: string }) => startCodexLensLsp(path), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: codexLensKeys.lspStatus() }); + success( + formatMessage({ id: 'common.success' }), + formatMessage({ id: 'codexlens.lsp.started' }) + ); + }, + onError: (err) => { + const sanitized = sanitizeErrorMessage(err, 'codexLensLspStart'); + const message = formatMessage({ id: sanitized.messageKey }); + const title = formatMessage({ id: 'common.error' }); + errorToast(title, message); + }, + }); + + const stopMutation = useMutation({ + mutationFn: ({ path }: { path?: string }) => stopCodexLensLsp(path), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: codexLensKeys.lspStatus() }); + success( + formatMessage({ id: 'common.success' }), + formatMessage({ id: 'codexlens.lsp.stopped' }) + ); + }, + onError: (err) => { + const sanitized = sanitizeErrorMessage(err, 'codexLensLspStop'); + const message = formatMessage({ id: sanitized.messageKey }); + const title = formatMessage({ id: 'common.error' }); + errorToast(title, message); + }, + }); + + const restartMutation = useMutation({ + mutationFn: ({ path }: { path?: string }) => restartCodexLensLsp(path), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: codexLensKeys.lspStatus() }); + success( + formatMessage({ id: 'common.success' }), + formatMessage({ id: 'codexlens.lsp.restarted' }) + ); + }, + onError: (err) => { + const sanitized = sanitizeErrorMessage(err, 'codexLensLspRestart'); + const message = formatMessage({ id: sanitized.messageKey }); + const title = formatMessage({ id: 'common.error' }); + errorToast(title, message); + }, + }); + + return { + startLsp: (path?: string) => startMutation.mutateAsync({ path }), + stopLsp: (path?: string) => stopMutation.mutateAsync({ path }), + restartLsp: (path?: string) => restartMutation.mutateAsync({ path }), + isStarting: startMutation.isPending, + isStopping: stopMutation.isPending, + isRestarting: restartMutation.isPending, + }; +} + export interface UseCodexLensSemanticSearchOptions { enabled?: boolean; } @@ -1568,3 +1667,127 @@ export function useCodexLensWatcherMutations(): UseCodexLensWatcherMutationsRetu isStopping: stopMutation.isPending, }; } + +// ========== Reranker Config Hooks ========== + +export interface UseCodexLensRerankerConfigOptions { + enabled?: boolean; + staleTime?: number; +} + +export interface UseCodexLensRerankerConfigReturn { + data: RerankerConfigResponse | undefined; + backend: string; + modelName: string; + apiProvider: string; + apiKeySet: boolean; + availableBackends: string[]; + apiProviders: string[]; + litellmModels: RerankerConfigResponse['litellm_models']; + configSource: string; + isLoading: boolean; + error: Error | null; + refetch: () => Promise; +} + +/** + * Hook for fetching reranker configuration (backends, models, providers) + */ +export function useCodexLensRerankerConfig( + options: UseCodexLensRerankerConfigOptions = {} +): UseCodexLensRerankerConfigReturn { + const { enabled = true, staleTime = STALE_TIME_MEDIUM } = options; + + const query = useQuery({ + queryKey: codexLensKeys.rerankerConfig(), + queryFn: fetchRerankerConfig, + staleTime, + enabled, + retry: 2, + }); + + const refetch = async () => { + await query.refetch(); + }; + + return { + data: query.data, + backend: query.data?.backend ?? 'fastembed', + modelName: query.data?.model_name ?? '', + apiProvider: query.data?.api_provider ?? '', + apiKeySet: query.data?.api_key_set ?? false, + availableBackends: query.data?.available_backends ?? [], + apiProviders: query.data?.api_providers ?? [], + litellmModels: query.data?.litellm_models, + configSource: query.data?.config_source ?? 'default', + isLoading: query.isLoading, + error: query.error, + refetch, + }; +} + +export interface UseUpdateRerankerConfigReturn { + updateConfig: (request: RerankerConfigUpdateRequest) => Promise; + isUpdating: boolean; + error: Error | null; +} + +/** + * Hook for updating reranker configuration + */ +export function useUpdateRerankerConfig(): UseUpdateRerankerConfigReturn { + const queryClient = useQueryClient(); + const formatMessage = useFormatMessage(); + const { success, error: errorToast } = useNotifications(); + + const mutation = useMutation({ + mutationFn: (request: RerankerConfigUpdateRequest) => updateRerankerConfig(request), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: codexLensKeys.rerankerConfig() }); + queryClient.invalidateQueries({ queryKey: codexLensKeys.env() }); + success( + formatMessage({ id: 'common.success' }), + formatMessage({ id: 'codexlens.reranker.saveSuccess' }) + ); + }, + onError: (err) => { + const sanitized = sanitizeErrorMessage(err, 'rerankerConfigUpdate'); + const message = formatMessage({ id: sanitized.messageKey }); + const title = formatMessage({ id: 'common.error' }); + errorToast(title, message); + }, + }); + + return { + updateConfig: mutation.mutateAsync, + isUpdating: mutation.isPending, + error: mutation.error, + }; +} + +// ========== CCW Tools Hook ========== + +export interface UseCcwToolsListReturn { + tools: CcwToolInfo[]; + isLoading: boolean; + error: Error | null; +} + +/** + * Hook for fetching all registered CCW tools + * Uses LONG stale time since tool list rarely changes + */ +export function useCcwToolsList(): UseCcwToolsListReturn { + const query = useQuery({ + queryKey: codexLensKeys.ccwTools(), + queryFn: fetchCcwTools, + staleTime: STALE_TIME_LONG, + retry: 2, + }); + + return { + tools: query.data ?? [], + isLoading: query.isLoading, + error: query.error, + }; +} diff --git a/ccw/frontend/src/lib/api.ts b/ccw/frontend/src/lib/api.ts index 3e5e288a..533d7c16 100644 --- a/ccw/frontend/src/lib/api.ts +++ b/ccw/frontend/src/lib/api.ts @@ -3411,7 +3411,7 @@ export interface CcwMcpConfig { enabledTools: string[]; projectRoot?: string; allowedDirs?: string; - disableSandbox?: boolean; + enableSandbox?: boolean; } /** @@ -3426,7 +3426,7 @@ function buildCcwMcpServerConfig(config: { enabledTools?: string[]; projectRoot?: string; allowedDirs?: string; - disableSandbox?: boolean; + enableSandbox?: boolean; }): { command: string; args: string[]; env: Record } { const env: Record = {}; @@ -3442,8 +3442,8 @@ function buildCcwMcpServerConfig(config: { if (config.allowedDirs) { env.CCW_ALLOWED_DIRS = config.allowedDirs; } - if (config.disableSandbox) { - env.CCW_DISABLE_SANDBOX = '1'; + if (config.enableSandbox) { + env.CCW_ENABLE_SANDBOX = '1'; } // Cross-platform config @@ -3508,7 +3508,7 @@ export async function fetchCcwMcpConfig(): Promise { enabledTools, projectRoot: env.CCW_PROJECT_ROOT, allowedDirs: env.CCW_ALLOWED_DIRS, - disableSandbox: env.CCW_DISABLE_SANDBOX === '1', + enableSandbox: env.CCW_ENABLE_SANDBOX === '1', }; } catch { return { @@ -3525,7 +3525,7 @@ export async function updateCcwConfig(config: { enabledTools?: string[]; projectRoot?: string; allowedDirs?: string; - disableSandbox?: boolean; + enableSandbox?: boolean; }): Promise { const serverConfig = buildCcwMcpServerConfig(config); @@ -3630,7 +3630,7 @@ export async function fetchCcwMcpConfigForCodex(): Promise { enabledTools, projectRoot: env.CCW_PROJECT_ROOT, allowedDirs: env.CCW_ALLOWED_DIRS, - disableSandbox: env.CCW_DISABLE_SANDBOX === '1', + enableSandbox: env.CCW_ENABLE_SANDBOX === '1', }; } catch { return { isInstalled: false, enabledTools: [] }; @@ -3644,7 +3644,7 @@ function buildCcwMcpServerConfigForCodex(config: { enabledTools?: string[]; projectRoot?: string; allowedDirs?: string; - disableSandbox?: boolean; + enableSandbox?: boolean; }): { command: string; args: string[]; env: Record } { const env: Record = {}; @@ -3660,8 +3660,8 @@ function buildCcwMcpServerConfigForCodex(config: { if (config.allowedDirs) { env.CCW_ALLOWED_DIRS = config.allowedDirs; } - if (config.disableSandbox) { - env.CCW_DISABLE_SANDBOX = '1'; + if (config.enableSandbox) { + env.CCW_ENABLE_SANDBOX = '1'; } return { command: 'ccw-mcp', args: [], env }; @@ -3700,7 +3700,7 @@ export async function updateCcwConfigForCodex(config: { enabledTools?: string[]; projectRoot?: string; allowedDirs?: string; - disableSandbox?: boolean; + enableSandbox?: boolean; }): Promise { const serverConfig = buildCcwMcpServerConfigForCodex(config); @@ -4540,6 +4540,74 @@ export async function updateCodexLensIgnorePatterns(request: CodexLensUpdateIgno }); } +// ========== CodexLens Reranker Config API ========== + +/** + * Reranker LiteLLM model info + */ +export interface RerankerLitellmModel { + modelId: string; + modelName: string; + providers: string[]; +} + +/** + * Reranker configuration response from GET /api/codexlens/reranker/config + */ +export interface RerankerConfigResponse { + success: boolean; + backend: string; + model_name: string; + api_provider: string; + api_key_set: boolean; + available_backends: string[]; + api_providers: string[]; + litellm_endpoints: string[]; + litellm_models?: RerankerLitellmModel[]; + config_source: string; + error?: string; +} + +/** + * Reranker configuration update request for POST /api/codexlens/reranker/config + */ +export interface RerankerConfigUpdateRequest { + backend?: string; + model_name?: string; + api_provider?: string; + api_key?: string; + litellm_endpoint?: string; +} + +/** + * Reranker configuration update response + */ +export interface RerankerConfigUpdateResponse { + success: boolean; + message?: string; + updates?: string[]; + error?: string; +} + +/** + * Fetch reranker configuration (backends, models, providers) + */ +export async function fetchRerankerConfig(): Promise { + return fetchApi('/api/codexlens/reranker/config'); +} + +/** + * Update reranker configuration + */ +export async function updateRerankerConfig( + request: RerankerConfigUpdateRequest +): Promise { + return fetchApi('/api/codexlens/reranker/config', { + method: 'POST', + body: JSON.stringify(request), + }); +} + // ========== CodexLens Search API ========== /** @@ -4709,6 +4777,36 @@ export async function fetchCodexLensLspStatus(): Promise('/api/codexlens/lsp/status'); } +/** + * Start CodexLens LSP server + */ +export async function startCodexLensLsp(path?: string): Promise<{ success: boolean; message?: string; workspace_root?: string; error?: string }> { + return fetchApi('/api/codexlens/lsp/start', { + method: 'POST', + body: JSON.stringify({ path }), + }); +} + +/** + * Stop CodexLens LSP server + */ +export async function stopCodexLensLsp(path?: string): Promise<{ success: boolean; message?: string; error?: string }> { + return fetchApi('/api/codexlens/lsp/stop', { + method: 'POST', + body: JSON.stringify({ path }), + }); +} + +/** + * Restart CodexLens LSP server + */ +export async function restartCodexLensLsp(path?: string): Promise<{ success: boolean; message?: string; workspace_root?: string; error?: string }> { + return fetchApi('/api/codexlens/lsp/restart', { + method: 'POST', + body: JSON.stringify({ path }), + }); +} + /** * Perform semantic search using CodexLens Python API */ @@ -5845,6 +5943,25 @@ export async function upgradeCcwInstallation( }); } +// ========== CCW Tools API ========== + +/** + * CCW tool info returned by /api/ccw/tools + */ +export interface CcwToolInfo { + name: string; + description: string; + parameters?: Record; +} + +/** + * Fetch all registered CCW tools + */ +export async function fetchCcwTools(): Promise { + const data = await fetchApi<{ tools: CcwToolInfo[] }>('/api/ccw/tools'); + return data.tools; +} + // ========== Team API ========== export async function fetchTeams(): Promise<{ teams: Array<{ name: string; messageCount: number; lastActivity: string }> }> { diff --git a/ccw/frontend/src/locales/en/codexlens.json b/ccw/frontend/src/locales/en/codexlens.json index b6da6d8d..a91d3fbf 100644 --- a/ccw/frontend/src/locales/en/codexlens.json +++ b/ccw/frontend/src/locales/en/codexlens.json @@ -255,6 +255,31 @@ "description": "Please install CodexLens to use semantic code search features." } }, + "reranker": { + "title": "Reranker Configuration", + "description": "Configure the reranker backend, model, and provider for search result ranking.", + "backend": "Backend", + "backendHint": "Inference backend for reranking", + "model": "Model", + "modelHint": "Reranker model name or LiteLLM endpoint", + "provider": "API Provider", + "providerHint": "API provider for reranker service", + "apiKeyStatus": "API Key", + "apiKeySet": "Configured", + "apiKeyNotSet": "Not configured", + "configSource": "Config Source", + "save": "Save Reranker Config", + "saving": "Saving...", + "saveSuccess": "Reranker configuration saved", + "saveFailed": "Failed to save reranker configuration", + "noBackends": "No backends available", + "noModels": "No models available", + "noProviders": "No providers available", + "litellmModels": "LiteLLM Models", + "selectBackend": "Select backend...", + "selectModel": "Select model...", + "selectProvider": "Select provider..." + }, "envGroup": { "embedding": "Embedding", "reranker": "Reranker", @@ -309,6 +334,16 @@ "installNow": "Install Now", "installing": "Installing..." }, + "mcp": { + "title": "CCW Tools Registry", + "loading": "Loading tools...", + "error": "Failed to load tools", + "errorDesc": "Unable to fetch CCW tools list. Please check if the server is running.", + "emptyDesc": "No tools are currently registered.", + "totalCount": "{count} tools", + "codexLensSection": "CodexLens Tools", + "otherSection": "Other Tools" + }, "watcher": { "title": "File Watcher", "status": { @@ -323,5 +358,27 @@ "stopping": "Stopping...", "started": "File watcher started", "stopped": "File watcher stopped" + }, + "lsp": { + "title": "LSP Server", + "status": { + "running": "Running", + "stopped": "Stopped" + }, + "projects": "Projects", + "embeddings": "Embeddings", + "modes": "Modes", + "semanticAvailable": "Semantic", + "available": "Available", + "unavailable": "Unavailable", + "start": "Start Server", + "starting": "Starting...", + "stop": "Stop Server", + "stopping": "Stopping...", + "restart": "Restart", + "restarting": "Restarting...", + "started": "LSP server started", + "stopped": "LSP server stopped", + "restarted": "LSP server restarted" } } diff --git a/ccw/frontend/src/locales/zh/codexlens.json b/ccw/frontend/src/locales/zh/codexlens.json index c3ddfacb..3a4273cc 100644 --- a/ccw/frontend/src/locales/zh/codexlens.json +++ b/ccw/frontend/src/locales/zh/codexlens.json @@ -255,6 +255,31 @@ "description": "请先安装 CodexLens 以使用语义代码搜索功能。" } }, + "reranker": { + "title": "重排序配置", + "description": "配置重排序后端、模型和提供商,用于搜索结果排序。", + "backend": "后端", + "backendHint": "重排序推理后端", + "model": "模型", + "modelHint": "重排序模型名称或 LiteLLM 端点", + "provider": "API 提供商", + "providerHint": "重排序服务的 API 提供商", + "apiKeyStatus": "API 密钥", + "apiKeySet": "已配置", + "apiKeyNotSet": "未配置", + "configSource": "配置来源", + "save": "保存重排序配置", + "saving": "保存中...", + "saveSuccess": "重排序配置已保存", + "saveFailed": "保存重排序配置失败", + "noBackends": "无可用后端", + "noModels": "无可用模型", + "noProviders": "无可用提供商", + "litellmModels": "LiteLLM 模型", + "selectBackend": "选择后端...", + "selectModel": "选择模型...", + "selectProvider": "选择提供商..." + }, "envGroup": { "embedding": "嵌入模型", "reranker": "重排序", @@ -309,6 +334,16 @@ "installNow": "立即安装", "installing": "安装中..." }, + "mcp": { + "title": "CCW 工具注册表", + "loading": "加载工具中...", + "error": "加载工具失败", + "errorDesc": "无法获取 CCW 工具列表。请检查服务器是否正在运行。", + "emptyDesc": "当前没有已注册的工具。", + "totalCount": "{count} 个工具", + "codexLensSection": "CodexLens 工具", + "otherSection": "其他工具" + }, "watcher": { "title": "文件监听器", "status": { @@ -323,5 +358,27 @@ "stopping": "停止中...", "started": "文件监听器已启动", "stopped": "文件监听器已停止" + }, + "lsp": { + "title": "LSP 服务器", + "status": { + "running": "运行中", + "stopped": "已停止" + }, + "projects": "项目数", + "embeddings": "嵌入模型", + "modes": "模式", + "semanticAvailable": "语义搜索", + "available": "可用", + "unavailable": "不可用", + "start": "启动服务", + "starting": "启动中...", + "stop": "停止服务", + "stopping": "停止中...", + "restart": "重启", + "restarting": "重启中...", + "started": "LSP 服务器已启动", + "stopped": "LSP 服务器已停止", + "restarted": "LSP 服务器已重启" } } diff --git a/ccw/frontend/src/test/i18n.tsx b/ccw/frontend/src/test/i18n.tsx index 39d73700..a28de23f 100644 --- a/ccw/frontend/src/test/i18n.tsx +++ b/ccw/frontend/src/test/i18n.tsx @@ -251,6 +251,30 @@ const mockMessages: Record> = { 'codexlens.models.notInstalled.description': 'Please install CodexLens to use model management features.', 'codexlens.models.empty.title': 'No models found', 'codexlens.models.empty.description': 'Try adjusting your search or filter criteria', + // Reranker + 'codexlens.reranker.title': 'Reranker Configuration', + 'codexlens.reranker.description': 'Configure the reranker backend, model, and provider for search result ranking.', + 'codexlens.reranker.backend': 'Backend', + 'codexlens.reranker.backendHint': 'Inference backend for reranking', + 'codexlens.reranker.model': 'Model', + 'codexlens.reranker.modelHint': 'Reranker model name or LiteLLM endpoint', + 'codexlens.reranker.provider': 'API Provider', + 'codexlens.reranker.providerHint': 'API provider for reranker service', + 'codexlens.reranker.apiKeyStatus': 'API Key', + 'codexlens.reranker.apiKeySet': 'Configured', + 'codexlens.reranker.apiKeyNotSet': 'Not configured', + 'codexlens.reranker.configSource': 'Config Source', + 'codexlens.reranker.save': 'Save Reranker Config', + 'codexlens.reranker.saving': 'Saving...', + 'codexlens.reranker.saveSuccess': 'Reranker configuration saved', + 'codexlens.reranker.saveFailed': 'Failed to save reranker configuration', + 'codexlens.reranker.noBackends': 'No backends available', + 'codexlens.reranker.noModels': 'No models available', + 'codexlens.reranker.noProviders': 'No providers available', + 'codexlens.reranker.litellmModels': 'LiteLLM Models', + 'codexlens.reranker.selectBackend': 'Select backend...', + 'codexlens.reranker.selectModel': 'Select model...', + 'codexlens.reranker.selectProvider': 'Select provider...', 'navigation.codexlens': 'CodexLens', }, zh: { @@ -491,6 +515,30 @@ const mockMessages: Record> = { 'codexlens.models.notInstalled.description': '请先安装 CodexLens 以使用模型管理功能。', 'codexlens.models.empty.title': '没有找到模型', 'codexlens.models.empty.description': '尝试调整搜索或筛选条件', + // Reranker + 'codexlens.reranker.title': '重排序配置', + 'codexlens.reranker.description': '配置重排序后端、模型和提供商,用于搜索结果排序。', + 'codexlens.reranker.backend': '后端', + 'codexlens.reranker.backendHint': '重排序推理后端', + 'codexlens.reranker.model': '模型', + 'codexlens.reranker.modelHint': '重排序模型名称或 LiteLLM 端点', + 'codexlens.reranker.provider': 'API 提供商', + 'codexlens.reranker.providerHint': '重排序服务的 API 提供商', + 'codexlens.reranker.apiKeyStatus': 'API 密钥', + 'codexlens.reranker.apiKeySet': '已配置', + 'codexlens.reranker.apiKeyNotSet': '未配置', + 'codexlens.reranker.configSource': '配置来源', + 'codexlens.reranker.save': '保存重排序配置', + 'codexlens.reranker.saving': '保存中...', + 'codexlens.reranker.saveSuccess': '重排序配置已保存', + 'codexlens.reranker.saveFailed': '保存重排序配置失败', + 'codexlens.reranker.noBackends': '无可用后端', + 'codexlens.reranker.noModels': '无可用模型', + 'codexlens.reranker.noProviders': '无可用提供商', + 'codexlens.reranker.litellmModels': 'LiteLLM 模型', + 'codexlens.reranker.selectBackend': '选择后端...', + 'codexlens.reranker.selectModel': '选择模型...', + 'codexlens.reranker.selectProvider': '选择提供商...', 'navigation.codexlens': 'CodexLens', }, }; diff --git a/ccw/src/core/routes/codexlens/semantic-handlers.ts b/ccw/src/core/routes/codexlens/semantic-handlers.ts index 96dd7ea5..7e20cbc4 100644 --- a/ccw/src/core/routes/codexlens/semantic-handlers.ts +++ b/ccw/src/core/routes/codexlens/semantic-handlers.ts @@ -1079,6 +1079,106 @@ except Exception as e: return true; } + // API: LSP Start - Start the standalone LSP manager + if (pathname === '/api/codexlens/lsp/start' && req.method === 'POST') { + handlePostRequest(req, res, async (body) => { + const { path: workspacePath } = body as { path?: unknown }; + const targetPath = typeof workspacePath === 'string' && workspacePath.trim().length > 0 + ? workspacePath : initialPath; + + try { + const venvStatus = await checkVenvStatus(); + if (!venvStatus.ready) { + return { success: false, error: 'CodexLens not installed', status: 400 }; + } + + const result = await executeCodexLensPythonAPI('lsp_start', { + workspace_root: targetPath, + }, 30000); + + if (result.success) { + return { + success: true, + message: 'LSP server started', + workspace_root: targetPath, + ...((result.results && typeof result.results === 'object') ? result.results : {}), + }; + } else { + return { success: false, error: result.error || 'Failed to start LSP server', status: 500 }; + } + } catch (err: unknown) { + return { success: false, error: err instanceof Error ? err.message : String(err), status: 500 }; + } + }); + return true; + } + + // API: LSP Stop - Stop the standalone LSP manager + if (pathname === '/api/codexlens/lsp/stop' && req.method === 'POST') { + handlePostRequest(req, res, async (body) => { + const { path: workspacePath } = body as { path?: unknown }; + const targetPath = typeof workspacePath === 'string' && workspacePath.trim().length > 0 + ? workspacePath : initialPath; + + try { + const venvStatus = await checkVenvStatus(); + if (!venvStatus.ready) { + return { success: false, error: 'CodexLens not installed', status: 400 }; + } + + const result = await executeCodexLensPythonAPI('lsp_stop', { + workspace_root: targetPath, + }, 15000); + + if (result.success) { + return { + success: true, + message: 'LSP server stopped', + }; + } else { + return { success: false, error: result.error || 'Failed to stop LSP server', status: 500 }; + } + } catch (err: unknown) { + return { success: false, error: err instanceof Error ? err.message : String(err), status: 500 }; + } + }); + return true; + } + + // API: LSP Restart - Stop then start the standalone LSP manager + if (pathname === '/api/codexlens/lsp/restart' && req.method === 'POST') { + handlePostRequest(req, res, async (body) => { + const { path: workspacePath } = body as { path?: unknown }; + const targetPath = typeof workspacePath === 'string' && workspacePath.trim().length > 0 + ? workspacePath : initialPath; + + try { + const venvStatus = await checkVenvStatus(); + if (!venvStatus.ready) { + return { success: false, error: 'CodexLens not installed', status: 400 }; + } + + const result = await executeCodexLensPythonAPI('lsp_restart', { + workspace_root: targetPath, + }, 45000); + + if (result.success) { + return { + success: true, + message: 'LSP server restarted', + workspace_root: targetPath, + ...((result.results && typeof result.results === 'object') ? result.results : {}), + }; + } else { + return { success: false, error: result.error || 'Failed to restart LSP server', status: 500 }; + } + } catch (err: unknown) { + return { success: false, error: err instanceof Error ? err.message : String(err), status: 500 }; + } + }); + return true; + } + // API: LSP Semantic Search - Advanced semantic search via Python API if (pathname === '/api/codexlens/lsp/search' && req.method === 'POST') { handlePostRequest(req, res, async (body) => { diff --git a/codex-lens/src/codexlens/api/__init__.py b/codex-lens/src/codexlens/api/__init__.py index 6312ece5..fd961a56 100644 --- a/codex-lens/src/codexlens/api/__init__.py +++ b/codex-lens/src/codexlens/api/__init__.py @@ -61,6 +61,7 @@ from .hover import get_hover from .file_context import file_context from .references import find_references from .semantic import semantic_search +from .lsp_lifecycle import lsp_start, lsp_stop, lsp_restart __all__ = [ # Dataclasses @@ -85,4 +86,8 @@ __all__ = [ "file_context", "find_references", "semantic_search", + # LSP lifecycle + "lsp_start", + "lsp_stop", + "lsp_restart", ] diff --git a/codex-lens/src/codexlens/api/lsp_lifecycle.py b/codex-lens/src/codexlens/api/lsp_lifecycle.py new file mode 100644 index 00000000..ebda4691 --- /dev/null +++ b/codex-lens/src/codexlens/api/lsp_lifecycle.py @@ -0,0 +1,124 @@ +"""LSP server lifecycle management API. + +Provides synchronous wrappers around StandaloneLspManager's async +start/stop methods for use via the executeCodexLensPythonAPI bridge. +""" + +from __future__ import annotations + +import asyncio +import shutil +from typing import Any, Dict + + +def lsp_start(workspace_root: str) -> Dict[str, Any]: + """Start the standalone LSP manager and report configured servers. + + Loads configuration and checks which language server commands are + available on the system. Does NOT start individual language servers + (they start on demand when a file of that type is opened). + + Args: + workspace_root: Absolute path to the workspace root directory. + + Returns: + Dict with keys: servers (list of server info dicts), + workspace_root (str). + """ + from codexlens.lsp.standalone_manager import StandaloneLspManager + + async def _run() -> Dict[str, Any]: + manager = StandaloneLspManager(workspace_root=workspace_root) + await manager.start() + + servers = [] + for language_id, cfg in sorted(manager._configs.items()): + cmd0 = cfg.command[0] if cfg.command else None + servers.append({ + "language_id": language_id, + "display_name": cfg.display_name, + "extensions": list(cfg.extensions), + "command": list(cfg.command), + "command_available": bool(shutil.which(cmd0)) if cmd0 else False, + }) + + # Stop the manager - individual servers are started on demand + await manager.stop() + + return { + "servers": servers, + "server_count": len(servers), + "workspace_root": workspace_root, + } + + return asyncio.run(_run()) + + +def lsp_stop(workspace_root: str) -> Dict[str, Any]: + """Stop all running language servers for the given workspace. + + Creates a temporary manager instance, starts it (loads config), + then immediately stops it -- which terminates any running server + processes that match this workspace root. + + Args: + workspace_root: Absolute path to the workspace root directory. + + Returns: + Dict confirming shutdown. + """ + from codexlens.lsp.standalone_manager import StandaloneLspManager + + async def _run() -> Dict[str, Any]: + manager = StandaloneLspManager(workspace_root=workspace_root) + await manager.start() + await manager.stop() + return {"stopped": True} + + return asyncio.run(_run()) + + +def lsp_restart(workspace_root: str) -> Dict[str, Any]: + """Restart the standalone LSP manager (stop then start). + + Equivalent to calling lsp_stop followed by lsp_start, but avoids + the overhead of two separate Python process invocations. + + Args: + workspace_root: Absolute path to the workspace root directory. + + Returns: + Dict with keys: servers, server_count, workspace_root. + """ + from codexlens.lsp.standalone_manager import StandaloneLspManager + + async def _run() -> Dict[str, Any]: + # Stop phase + stop_manager = StandaloneLspManager(workspace_root=workspace_root) + await stop_manager.start() + await stop_manager.stop() + + # Start phase + start_manager = StandaloneLspManager(workspace_root=workspace_root) + await start_manager.start() + + servers = [] + for language_id, cfg in sorted(start_manager._configs.items()): + cmd0 = cfg.command[0] if cfg.command else None + servers.append({ + "language_id": language_id, + "display_name": cfg.display_name, + "extensions": list(cfg.extensions), + "command": list(cfg.command), + "command_available": bool(shutil.which(cmd0)) if cmd0 else False, + }) + + await start_manager.stop() + + return { + "servers": servers, + "server_count": len(servers), + "workspace_root": workspace_root, + } + + return asyncio.run(_run())