feat: add useApiSettings hook for managing API settings, including providers, endpoints, cache, and model pools

- Implemented hooks for CRUD operations on providers and endpoints.
- Added cache management hooks for cache stats and settings.
- Introduced model pool management hooks for high availability and load balancing.
- Created localization files for English and Chinese translations of API settings.
This commit is contained in:
catlog22
2026-02-01 23:14:55 +08:00
parent b76424feef
commit e5252f8a77
27 changed files with 4370 additions and 201 deletions

View File

@@ -5,7 +5,7 @@
import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import { Save, RefreshCw, AlertTriangle, FileCode } from 'lucide-react';
import { Save, RefreshCw, AlertTriangle, FileCode, AlertCircle } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Textarea } from '@/components/ui/Textarea';
import { Button } from '@/components/ui/Button';
@@ -32,6 +32,7 @@ export function AdvancedTab({ enabled = true }: AdvancedTabProps) {
env,
settings,
isLoading: isLoadingEnv,
error: envError,
refetch,
} = useCodexLensEnv({ enabled });
@@ -43,23 +44,25 @@ export function AdvancedTab({ enabled = true }: AdvancedTabProps) {
const [hasChanges, setHasChanges] = useState(false);
const [showWarning, setShowWarning] = useState(false);
// Initialize form from env
// Initialize form from env - handles both undefined (loading) and empty string (empty file)
// The hook returns raw directly, so we check if it's been set (not undefined means data loaded)
useEffect(() => {
if (raw !== undefined) {
setEnvInput(raw);
// Initialize when data is loaded (raw may be empty string but not undefined during loading)
// Note: During initial load, raw is undefined. After load completes, raw is set (even if empty string)
if (!isLoadingEnv) {
setEnvInput(raw ?? ''); // Use empty string if raw is undefined/null
setErrors({});
setHasChanges(false);
setShowWarning(false);
}
}, [raw]);
}, [raw, isLoadingEnv]);
const handleEnvChange = (value: string) => {
setEnvInput(value);
// Check if there are changes
if (raw !== undefined) {
setHasChanges(value !== raw);
setShowWarning(value !== raw);
}
// Check if there are changes - compare with raw value (handle undefined as empty)
const currentRaw = raw ?? '';
setHasChanges(value !== currentRaw);
setShowWarning(value !== currentRaw);
if (errors.env) {
setErrors((prev) => ({ ...prev, env: undefined }));
}
@@ -132,12 +135,11 @@ export function AdvancedTab({ enabled = true }: AdvancedTabProps) {
};
const handleReset = () => {
if (raw !== undefined) {
setEnvInput(raw);
setErrors({});
setHasChanges(false);
setShowWarning(false);
}
// Reset to current raw value (handle undefined as empty)
setEnvInput(raw ?? '');
setErrors({});
setHasChanges(false);
setShowWarning(false);
};
const isLoading = isLoadingEnv;
@@ -154,6 +156,32 @@ export function AdvancedTab({ enabled = true }: AdvancedTabProps) {
return (
<div className="space-y-6">
{/* Error Card */}
{envError && (
<Card className="p-4 bg-destructive/10 border-destructive/20">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-sm font-medium text-destructive-foreground">
{formatMessage({ id: 'codexlens.advanced.loadError' })}
</h4>
<p className="text-xs text-destructive-foreground/80 mt-1">
{envError.message || formatMessage({ id: 'codexlens.advanced.loadErrorDesc' })}
</p>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
className="mt-2"
>
<RefreshCw className="w-3 h-3 mr-1" />
{formatMessage({ id: 'common.actions.retry' })}
</Button>
</div>
</div>
</Card>
)}
{/* Sensitivity Warning Card */}
{showWarning && (
<Card className="p-4 bg-warning/10 border-warning/20">

View File

@@ -0,0 +1,286 @@
// ========================================
// CodexLens Index Operations Component
// ========================================
// Index management operations with progress tracking
import { useIntl } from 'react-intl';
import { useEffect, useState } from 'react';
import {
RotateCw,
Zap,
AlertCircle,
CheckCircle2,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Progress } from '@/components/ui/Progress';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
import { cn } from '@/lib/utils';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import {
useCodexLensIndexingStatus,
useRebuildIndex,
useUpdateIndex,
useCancelIndexing,
} from '@/hooks';
import { useNotifications } from '@/hooks/useNotifications';
import { useWebSocket } from '@/hooks/useWebSocket';
interface IndexOperationsProps {
disabled?: boolean;
onRefresh?: () => void;
}
interface IndexProgress {
stage: string;
message: string;
percent: number;
path?: string;
}
type IndexOperation = {
id: string;
type: 'fts_full' | 'fts_incremental' | 'vector_full' | 'vector_incremental';
label: string;
description: string;
icon: React.ReactNode;
};
export function IndexOperations({ disabled = false, onRefresh }: IndexOperationsProps) {
const { formatMessage } = useIntl();
const { success, error: showError } = useNotifications();
const projectPath = useWorkflowStore(selectProjectPath);
const { inProgress } = useCodexLensIndexingStatus();
const { rebuildIndex, isRebuilding } = useRebuildIndex();
const { updateIndex, isUpdating } = useUpdateIndex();
const { cancelIndexing, isCancelling } = useCancelIndexing();
const { lastMessage } = useWebSocket();
const [indexProgress, setIndexProgress] = useState<IndexProgress | null>(null);
const [activeOperation, setActiveOperation] = useState<string | null>(null);
// Listen for WebSocket progress updates
useEffect(() => {
if (lastMessage?.type === 'CODEXLENS_INDEX_PROGRESS') {
const progress = lastMessage.payload as IndexProgress;
setIndexProgress(progress);
// Clear active operation when complete or error
if (progress.stage === 'complete' || progress.stage === 'error' || progress.stage === 'cancelled') {
if (progress.stage === 'complete') {
success(
formatMessage({ id: 'codexlens.index.operationComplete' }),
progress.message
);
onRefresh?.();
} else if (progress.stage === 'error') {
showError(
formatMessage({ id: 'codexlens.index.operationFailed' }),
progress.message
);
}
setActiveOperation(null);
setIndexProgress(null);
}
}
}, [lastMessage, formatMessage, success, showError, onRefresh]);
const isOperating = isRebuilding || isUpdating || inProgress || !!activeOperation;
const handleOperation = async (operation: IndexOperation) => {
if (!projectPath) {
showError(
formatMessage({ id: 'codexlens.index.noProject' }),
formatMessage({ id: 'codexlens.index.noProjectDesc' })
);
return;
}
setActiveOperation(operation.id);
setIndexProgress({ stage: 'start', message: formatMessage({ id: 'codexlens.index.starting' }), percent: 0 });
try {
// Determine index type and operation
const isVector = operation.type.includes('vector');
const isIncremental = operation.type.includes('incremental');
if (isIncremental) {
const result = await updateIndex(projectPath, {
indexType: isVector ? 'vector' : 'normal',
});
if (!result.success) {
throw new Error(result.error || 'Update failed');
}
} else {
const result = await rebuildIndex(projectPath, {
indexType: isVector ? 'vector' : 'normal',
});
if (!result.success) {
throw new Error(result.error || 'Rebuild failed');
}
}
} catch (err) {
setActiveOperation(null);
setIndexProgress(null);
showError(
formatMessage({ id: 'codexlens.index.operationFailed' }),
err instanceof Error ? err.message : formatMessage({ id: 'codexlens.index.unknownError' })
);
}
};
const handleCancel = async () => {
const result = await cancelIndexing();
if (result.success) {
setActiveOperation(null);
setIndexProgress(null);
} else {
showError(
formatMessage({ id: 'codexlens.index.cancelFailed' }),
result.error || formatMessage({ id: 'codexlens.index.unknownError' })
);
}
};
const operations: IndexOperation[] = [
{
id: 'fts_full',
type: 'fts_full',
label: formatMessage({ id: 'codexlens.overview.actions.ftsFull' }),
description: formatMessage({ id: 'codexlens.overview.actions.ftsFullDesc' }),
icon: <RotateCw className="w-4 h-4" />,
},
{
id: 'fts_incremental',
type: 'fts_incremental',
label: formatMessage({ id: 'codexlens.overview.actions.ftsIncremental' }),
description: formatMessage({ id: 'codexlens.overview.actions.ftsIncrementalDesc' }),
icon: <Zap className="w-4 h-4" />,
},
{
id: 'vector_full',
type: 'vector_full',
label: formatMessage({ id: 'codexlens.overview.actions.vectorFull' }),
description: formatMessage({ id: 'codexlens.overview.actions.vectorFullDesc' }),
icon: <RotateCw className="w-4 h-4" />,
},
{
id: 'vector_incremental',
type: 'vector_incremental',
label: formatMessage({ id: 'codexlens.overview.actions.vectorIncremental' }),
description: formatMessage({ id: 'codexlens.overview.actions.vectorIncrementalDesc' }),
icon: <Zap className="w-4 h-4" />,
},
];
if (indexProgress && activeOperation) {
const operation = operations.find((op) => op.id === activeOperation);
const isComplete = indexProgress.stage === 'complete';
const isError = indexProgress.stage === 'error';
const isCancelled = indexProgress.stage === 'cancelled';
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center justify-between">
<span>{operation?.label}</span>
{!isComplete && !isError && !isCancelled && (
<Button
variant="ghost"
size="sm"
onClick={handleCancel}
disabled={isCancelling}
>
<X className="w-4 h-4 mr-1" />
{formatMessage({ id: 'common.actions.cancel' })}
</Button>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Status Icon */}
<div className="flex items-center gap-3">
{isComplete ? (
<CheckCircle2 className="w-6 h-6 text-success" />
) : isError || isCancelled ? (
<AlertCircle className="w-6 h-6 text-destructive" />
) : (
<RotateCw className="w-6 h-6 text-primary animate-spin" />
)}
<div className="flex-1">
<p className="text-sm font-medium text-foreground">
{isComplete
? formatMessage({ id: 'codexlens.index.complete' })
: isError
? formatMessage({ id: 'codexlens.index.failed' })
: isCancelled
? formatMessage({ id: 'codexlens.index.cancelled' })
: formatMessage({ id: 'codexlens.index.inProgress' })}
</p>
<p className="text-xs text-muted-foreground mt-1">{indexProgress.message}</p>
</div>
</div>
{/* Progress Bar */}
{!isComplete && !isError && !isCancelled && (
<div className="space-y-2">
<Progress value={indexProgress.percent} className="h-2" />
<p className="text-xs text-muted-foreground text-right">
{indexProgress.percent}%
</p>
</div>
)}
{/* Close Button */}
{(isComplete || isError || isCancelled) && (
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => {
setActiveOperation(null);
setIndexProgress(null);
}}
>
{formatMessage({ id: 'common.actions.close' })}
</Button>
</div>
)}
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base">
{formatMessage({ id: 'codexlens.overview.actions.title' })}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{operations.map((operation) => (
<Button
key={operation.id}
variant="outline"
className="h-auto p-4 flex flex-col items-start gap-2 text-left"
onClick={() => handleOperation(operation)}
disabled={disabled || isOperating}
>
<div className="flex items-center gap-2 w-full">
<span className={cn('text-muted-foreground', (disabled || isOperating) && 'opacity-50')}>
{operation.icon}
</span>
<span className="font-medium">{operation.label}</span>
</div>
<p className="text-xs text-muted-foreground">{operation.description}</p>
</Button>
))}
</div>
</CardContent>
</Card>
);
}
export default IndexOperations;

View File

@@ -10,6 +10,7 @@ import {
RefreshCw,
Package,
Filter,
AlertCircle,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
@@ -69,6 +70,7 @@ export function ModelsTab({ installed = false }: ModelsTabProps) {
const {
models,
isLoading,
error,
refetch,
} = useCodexLensModels({
enabled: installed,
@@ -243,7 +245,25 @@ export function ModelsTab({ installed = false }: ModelsTabProps) {
/>
{/* Model List */}
{isLoading ? (
{error ? (
<Card className="p-8 text-center">
<AlertCircle className="w-12 h-12 mx-auto text-destructive/50 mb-3" />
<h3 className="text-sm font-medium text-destructive-foreground mb-1">
{formatMessage({ id: 'codexlens.models.error.title' })}
</h3>
<p className="text-xs text-muted-foreground mb-3">
{error.message || formatMessage({ id: 'codexlens.models.error.description' })}
</p>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
>
<RefreshCw className="w-3 h-3 mr-1" />
{formatMessage({ id: 'common.actions.retry' })}
</Button>
</Card>
) : isLoading ? (
<Card className="p-8 text-center">
<p className="text-muted-foreground">{formatMessage({ id: 'common.actions.loading' })}</p>
</Card>
@@ -251,10 +271,16 @@ export function ModelsTab({ installed = false }: ModelsTabProps) {
<Card className="p-8 text-center">
<Package className="w-12 h-12 mx-auto text-muted-foreground/30 mb-3" />
<h3 className="text-sm font-medium text-foreground mb-1">
{formatMessage({ id: 'codexlens.models.empty.title' })}
{models && models.length > 0
? formatMessage({ id: 'codexlens.models.empty.filtered' })
: formatMessage({ id: 'codexlens.models.empty.title' })
}
</h3>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.models.empty.description' })}
{models && models.length > 0
? formatMessage({ id: 'codexlens.models.empty.filteredDesc' })
: formatMessage({ id: 'codexlens.models.empty.description' })
}
</p>
</Card>
) : (

View File

@@ -9,22 +9,22 @@ import {
FileText,
CheckCircle2,
XCircle,
RotateCw,
Zap,
} from 'lucide-react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils';
import type { CodexLensVenvStatus, CodexLensConfig } from '@/lib/api';
import { IndexOperations } from './IndexOperations';
interface OverviewTabProps {
installed: boolean;
status?: CodexLensVenvStatus;
config?: CodexLensConfig;
isLoading: boolean;
onRefresh?: () => void;
}
export function OverviewTab({ installed, status, config, isLoading }: OverviewTabProps) {
export function OverviewTab({ installed, status, config, isLoading, onRefresh }: OverviewTabProps) {
const { formatMessage } = useIntl();
if (isLoading) {
@@ -142,42 +142,8 @@ export function OverviewTab({ installed, status, config, isLoading }: OverviewTa
</Card>
</div>
{/* Quick Actions */}
<Card>
<CardHeader>
<CardTitle className="text-base">
{formatMessage({ id: 'codexlens.overview.actions.title' })}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<QuickActionButton
icon={<RotateCw className="w-4 h-4" />}
label={formatMessage({ id: 'codexlens.overview.actions.ftsFull' })}
description={formatMessage({ id: 'codexlens.overview.actions.ftsFullDesc' })}
disabled={!isReady}
/>
<QuickActionButton
icon={<Zap className="w-4 h-4" />}
label={formatMessage({ id: 'codexlens.overview.actions.ftsIncremental' })}
description={formatMessage({ id: 'codexlens.overview.actions.ftsIncrementalDesc' })}
disabled={!isReady}
/>
<QuickActionButton
icon={<RotateCw className="w-4 h-4" />}
label={formatMessage({ id: 'codexlens.overview.actions.vectorFull' })}
description={formatMessage({ id: 'codexlens.overview.actions.vectorFullDesc' })}
disabled={!isReady}
/>
<QuickActionButton
icon={<Zap className="w-4 h-4" />}
label={formatMessage({ id: 'codexlens.overview.actions.vectorIncremental' })}
description={formatMessage({ id: 'codexlens.overview.actions.vectorIncrementalDesc' })}
disabled={!isReady}
/>
</div>
</CardContent>
</Card>
{/* Index Operations */}
<IndexOperations disabled={!isReady} onRefresh={onRefresh} />
{/* Venv Details */}
{status && (
@@ -210,37 +176,3 @@ export function OverviewTab({ installed, status, config, isLoading }: OverviewTa
</div>
);
}
interface QuickActionButtonProps {
icon: React.ReactNode;
label: string;
description: string;
disabled?: boolean;
}
function QuickActionButton({ icon, label, description, disabled }: QuickActionButtonProps) {
const { formatMessage } = useIntl();
const handleClick = () => {
// TODO: Implement index operations in future tasks
// For now, show a message that this feature is coming soon
alert(formatMessage({ id: 'codexlens.comingSoon' }));
};
return (
<Button
variant="outline"
className="h-auto p-4 flex flex-col items-start gap-2 text-left"
onClick={handleClick}
disabled={disabled}
>
<div className="flex items-center gap-2 w-full">
<span className={cn('text-muted-foreground', disabled && 'opacity-50')}>
{icon}
</span>
<span className="font-medium">{label}</span>
</div>
<p className="text-xs text-muted-foreground">{description}</p>
</Button>
);
}

View File

@@ -0,0 +1,273 @@
// ========================================
// CodexLens Search Tab
// ========================================
// Semantic code search interface with multiple search types
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { Search, FileCode, Code } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/Select';
import {
useCodexLensSearch,
useCodexLensFilesSearch,
useCodexLensSymbolSearch,
} from '@/hooks/useCodexLens';
import type { CodexLensSearchParams } from '@/lib/api';
import { cn } from '@/lib/utils';
type SearchType = 'search' | 'search_files' | 'symbol';
type SearchMode = 'dense_rerank' | 'fts' | 'fuzzy';
interface SearchTabProps {
enabled: boolean;
}
export function SearchTab({ enabled }: SearchTabProps) {
const { formatMessage } = useIntl();
const [searchType, setSearchType] = useState<SearchType>('search');
const [searchMode, setSearchMode] = useState<SearchMode>('dense_rerank');
const [query, setQuery] = useState('');
const [hasSearched, setHasSearched] = useState(false);
// Build search params based on search type
const searchParams: CodexLensSearchParams = {
query,
limit: 20,
mode: searchType !== 'symbol' ? searchMode : undefined,
max_content_length: 200,
extra_files_count: 10,
};
// Search hooks - only enable when hasSearched is true and query is not empty
const contentSearch = useCodexLensSearch(
searchParams,
{ enabled: enabled && hasSearched && searchType === 'search' && query.trim().length > 0 }
);
const fileSearch = useCodexLensFilesSearch(
searchParams,
{ enabled: enabled && hasSearched && searchType === 'search_files' && query.trim().length > 0 }
);
const symbolSearch = useCodexLensSymbolSearch(
{ query, limit: 20 },
{ enabled: enabled && hasSearched && searchType === 'symbol' && query.trim().length > 0 }
);
// Get loading state based on search type
const isLoading = searchType === 'search'
? contentSearch.isLoading
: searchType === 'search_files'
? fileSearch.isLoading
: symbolSearch.isLoading;
const handleSearch = () => {
if (query.trim()) {
setHasSearched(true);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSearch();
}
};
const handleSearchTypeChange = (value: SearchType) => {
setSearchType(value);
setHasSearched(false); // Reset search state when changing type
};
const handleSearchModeChange = (value: SearchMode) => {
setSearchMode(value);
setHasSearched(false); // Reset search state when changing mode
};
const handleQueryChange = (value: string) => {
setQuery(value);
setHasSearched(false); // Reset search state when query changes
};
if (!enabled) {
return (
<div className="flex items-center justify-center p-12">
<div className="text-center">
<Search className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'codexlens.search.notInstalled.title' })}
</h3>
<p className="text-muted-foreground">
{formatMessage({ id: 'codexlens.search.notInstalled.description' })}
</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Search Options */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Search Type */}
<div className="space-y-2">
<Label>{formatMessage({ id: 'codexlens.search.type' })}</Label>
<Select value={searchType} onValueChange={handleSearchTypeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="search">
<div className="flex items-center gap-2">
<Search className="w-4 h-4" />
{formatMessage({ id: 'codexlens.search.content' })}
</div>
</SelectItem>
<SelectItem value="search_files">
<div className="flex items-center gap-2">
<FileCode className="w-4 h-4" />
{formatMessage({ id: 'codexlens.search.files' })}
</div>
</SelectItem>
<SelectItem value="symbol">
<div className="flex items-center gap-2">
<Code className="w-4 h-4" />
{formatMessage({ id: 'codexlens.search.symbol' })}
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Search Mode - only for content and file search */}
{searchType !== 'symbol' && (
<div className="space-y-2">
<Label>{formatMessage({ id: 'codexlens.search.mode' })}</Label>
<Select value={searchMode} onValueChange={handleSearchModeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="dense_rerank">
{formatMessage({ id: 'codexlens.search.mode.semantic' })}
</SelectItem>
<SelectItem value="fts">
{formatMessage({ id: 'codexlens.search.mode.exact' })}
</SelectItem>
<SelectItem value="fuzzy">
{formatMessage({ id: 'codexlens.search.mode.fuzzy' })}
</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{/* Query Input */}
<div className="space-y-2">
<Label htmlFor="search-query">{formatMessage({ id: 'codexlens.search.query' })}</Label>
<Input
id="search-query"
placeholder={formatMessage({ id: 'codexlens.search.queryPlaceholder' })}
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
onKeyPress={handleKeyPress}
disabled={isLoading}
/>
</div>
{/* Search Button */}
<Button
onClick={handleSearch}
disabled={!query.trim() || isLoading}
className="w-full"
>
<Search className={cn('w-4 h-4 mr-2', isLoading && 'animate-spin')} />
{isLoading
? formatMessage({ id: 'codexlens.search.searching' })
: formatMessage({ id: 'codexlens.search.button' })
}
</Button>
{/* Results */}
{hasSearched && !isLoading && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">
{formatMessage({ id: 'codexlens.search.results' })}
</h3>
<span className="text-xs text-muted-foreground">
{searchType === 'symbol'
? (symbolSearch.data?.success
? `${symbolSearch.data.symbols?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
: ''
)
: searchType === 'search'
? (contentSearch.data?.success
? `${contentSearch.data.results?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
: ''
)
: (fileSearch.data?.success
? `${fileSearch.data.results?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
: ''
)
}
</span>
</div>
{searchType === 'symbol' && symbolSearch.data && (
symbolSearch.data.success ? (
<div className="rounded-lg border bg-muted/50 p-4">
<pre className="text-xs overflow-auto max-h-96">
{JSON.stringify(symbolSearch.data.symbols, null, 2)}
</pre>
</div>
) : (
<div className="text-sm text-destructive">
{symbolSearch.data.error || formatMessage({ id: 'common.error' })}
</div>
)
)}
{searchType === 'search' && contentSearch.data && (
contentSearch.data.success ? (
<div className="rounded-lg border bg-muted/50 p-4">
<pre className="text-xs overflow-auto max-h-96">
{JSON.stringify(contentSearch.data.results, null, 2)}
</pre>
</div>
) : (
<div className="text-sm text-destructive">
{contentSearch.data.error || formatMessage({ id: 'common.error' })}
</div>
)
)}
{searchType === 'search_files' && fileSearch.data && (
fileSearch.data.success ? (
<div className="rounded-lg border bg-muted/50 p-4">
<pre className="text-xs overflow-auto max-h-96">
{JSON.stringify(fileSearch.data.results, null, 2)}
</pre>
</div>
) : (
<div className="text-sm text-destructive">
{fileSearch.data.error || formatMessage({ id: 'common.error' })}
</div>
)
)}
</div>
)}
</div>
);
}
export default SearchTab;

View File

@@ -19,6 +19,9 @@ export function ExecutionTab({ execution, isActive, onClick, onClose }: Executio
// Simplify tool name (e.g., gemini-2.5-pro -> gemini)
const toolNameShort = execution.tool.split('-')[0];
// Mode display - use icon for visual clarity
const modeDisplay = execution.mode === 'write' ? '✏️' : '🔍';
// Status color mapping - using softer, semantic colors
const statusColor = {
running: 'bg-indigo-500 shadow-[0_0_6px_rgba(99,102,241,0.4)] animate-pulse',
@@ -31,7 +34,7 @@ export function ExecutionTab({ execution, isActive, onClick, onClose }: Executio
value={execution.id}
onClick={onClick}
className={cn(
'gap-1.5 text-xs px-2.5 py-1 rounded-md border border-border/50 group',
'gap-1.5 text-xs px-2.5 py-1 rounded-md border border-border/50 group shrink-0',
isActive
? 'bg-slate-100 dark:bg-slate-800 border-slate-300 dark:border-slate-600 shadow-sm'
: 'bg-muted/30 hover:bg-muted/50 border-border/30',
@@ -41,14 +44,14 @@ export function ExecutionTab({ execution, isActive, onClick, onClose }: Executio
{/* Status indicator dot */}
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', statusColor)} />
{/* Mode indicator */}
<span className="text-[10px]" title={execution.mode}>
{modeDisplay}
</span>
{/* Simplified tool name */}
<span className="font-medium text-[11px]">{toolNameShort}</span>
{/* Execution mode - show on hover */}
<span className="opacity-0 group-hover:opacity-70 text-[10px] transition-opacity">
{execution.mode}
</span>
{/* Line count statistics - show on hover */}
<span className="opacity-0 group-hover:opacity-50 text-[9px] tabular-nums transition-opacity">
{execution.output.length}

View File

@@ -12,6 +12,114 @@ export interface JsonDetectionResult {
error?: string;
}
/**
* Try to recover truncated JSON by completing brackets
* This handles cases where JSON is split during streaming
*/
function tryRecoverTruncatedJson(content: string): Record<string, unknown> | null {
const trimmed = content.trim();
// Must start with { to be recoverable JSON
if (!trimmed.startsWith('{')) {
return null;
}
// Count opening vs closing braces
let openBraces = 0;
let closeBraces = 0;
let inString = false;
let escapeNext = false;
for (let i = 0; i < trimmed.length; i++) {
const char = trimmed[i];
if (escapeNext) {
escapeNext = false;
continue;
}
if (char === '\\') {
escapeNext = true;
continue;
}
if (char === '"') {
inString = !inString;
continue;
}
if (!inString) {
if (char === '{') openBraces++;
if (char === '}') closeBraces++;
}
}
// If we're missing closing braces, try to complete them
if (openBraces > closeBraces) {
const missingBraces = openBraces - closeBraces;
const recovered = trimmed + '}'.repeat(missingBraces);
// Also close any open quote
let finalRecovered = recovered;
if (inString) {
finalRecovered = recovered + '"';
// Add closing braces after the quote
finalRecovered = finalRecovered + '}'.repeat(missingBraces);
}
try {
return JSON.parse(finalRecovered) as Record<string, unknown>;
} catch {
// Recovery failed, try one more approach
}
}
// Try parsing as-is first
try {
return JSON.parse(trimmed) as Record<string, unknown>;
} catch {
// If still failing, try to close any hanging structures
// Remove trailing incomplete key/value and try again
const lastCommaIndex = trimmed.lastIndexOf(',');
if (lastCommaIndex > 0) {
const truncated = trimmed.substring(0, lastCommaIndex) + '}';
try {
return JSON.parse(truncated) as Record<string, unknown>;
} catch {
// Still failed
}
}
}
return null;
}
/**
* Detect token usage stats pattern (common in CLI output)
* Pattern: {"type":"result","status":"success","stats":{"total_tokens":...,"input_tokens":...,...}
*/
function detectTokenStats(content: string): Record<string, unknown> | null {
// Check for common token stat patterns
const patterns = [
/"type"\s*:\s*"result"/,
/"status"\s*:\s*"success"/,
/"stats"\s*:\s*\{/,
/"total_tokens"\s*:\s*\d+/,
];
const matchCount = patterns.filter(p => p.test(content)).length;
// If at least 3 patterns match, this is likely token stats
if (matchCount >= 3) {
const recovered = tryRecoverTruncatedJson(content);
if (recovered) {
return recovered;
}
}
return null;
}
/**
* Detect if a line contains JSON data
* Supports multiple formats:
@@ -20,17 +128,29 @@ export interface JsonDetectionResult {
* - Tool Result: [Tool Result] status: {...}
* - Embedded JSON: trailing JSON object
* - Code block JSON: ```json ... ```
* - Truncated JSON: handles streaming incomplete JSON
*/
export function detectJsonInLine(content: string): JsonDetectionResult {
const trimmed = content.trim();
// 1. Direct JSON object or array
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
// First try normal parse
try {
const parsed = JSON.parse(trimmed);
return { isJson: true, parsed: parsed as Record<string, unknown> };
} catch {
// Continue to other patterns
// Normal parse failed, try recovery for truncated JSON
const recovered = tryRecoverTruncatedJson(trimmed);
if (recovered) {
return { isJson: true, parsed: recovered };
}
// Check for token stats pattern specifically
const tokenStats = detectTokenStats(trimmed);
if (tokenStats) {
return { isJson: true, parsed: tokenStats };
}
}
}

View File

@@ -3,7 +3,7 @@
// ========================================
// Global CLI streaming monitor with multi-execution support
import { useEffect, useRef, useCallback, useState } from 'react';
import { useEffect, useRef, useCallback, useState, useMemo, memo } from 'react';
import { useIntl } from 'react-intl';
import {
X,
@@ -13,6 +13,7 @@ import {
RefreshCw,
Search,
ArrowDownToLine,
Trash2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
@@ -26,8 +27,6 @@ import { useActiveCliExecutions, useInvalidateActiveCliExecutions } from '@/hook
// New components for Tab + JSON Cards
import { ExecutionTab } from './CliStreamMonitor/components/ExecutionTab';
import { OutputLine } from './CliStreamMonitor/components/OutputLine';
import { JsonCard } from './CliStreamMonitor/components/JsonCard';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -95,55 +94,99 @@ function getBorderColorForType(type: CliOutputLine['type']): string {
}
/**
* Render a single output line as a card
* Extract content from a line (handle JSON with 'content' field)
*/
interface OutputLineCardProps {
line: CliOutputLine;
onCopy?: (content: string) => void;
}
function OutputLineCard({ line, onCopy }: OutputLineCardProps) {
const borderColor = getBorderColorForType(line.type);
function extractContentFromLine(line: CliOutputLine): { content: string; isMarkdown: boolean } {
const trimmed = line.content.trim();
// Check if line is JSON with 'content' field
let contentToRender = trimmed;
let isMarkdown = false;
try {
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
const parsed = JSON.parse(trimmed);
if ('content' in parsed && typeof parsed.content === 'string') {
contentToRender = parsed.content;
// Check if content looks like markdown
isMarkdown = !!contentToRender.match(/^#{1,6}\s|^\*{3,}$|^\s*[-*+]\s+|^\s*\d+\.\s+|\*\*.*?\*\*|`{3,}/m);
const content = parsed.content;
const isMarkdown = !!content.match(/^#{1,6}\s|^\*{3,}$|^\s*[-*+]\s+|^\s*\d+\.\s+|\*\*.*?\*\*|`{3,}/m);
return { content, isMarkdown };
}
}
} catch {
// Not valid JSON, use original content
// Check if original content looks like markdown
isMarkdown = !!trimmed.match(/^#{1,6}\s|^\*{3,}$|^\s*[-*+]\s+|^\s*\d+\.\s+|\*\*.*?\*\*|`{3,}/m);
}
// Check if original content looks like markdown
const isMarkdown = !!trimmed.match(/^#{1,6}\s|^\*{3,}$|^\s*[-*+]\s+|^\s*\d+\.\s+|\*\*.*?\*\*|`{3,}/m);
return { content: trimmed, isMarkdown };
}
/**
* Group consecutive output lines by type
*/
interface OutputLineGroup {
type: CliOutputLine['type'];
lines: CliOutputLine[];
}
function groupConsecutiveLinesByType(lines: CliOutputLine[]): OutputLineGroup[] {
const groups: OutputLineGroup[] = [];
for (const line of lines) {
// Start new group if type changes
if (groups.length === 0 || groups[groups.length - 1].type !== line.type) {
groups.push({
type: line.type,
lines: [line],
});
} else {
// Append to existing group
groups[groups.length - 1].lines.push(line);
}
}
return groups;
}
/**
* Render a group of output lines as a merged card
*/
interface OutputLineCardProps {
group: OutputLineGroup;
onCopy?: (content: string) => void;
}
function OutputLineCard({ group, onCopy }: OutputLineCardProps) {
const borderColor = getBorderColorForType(group.type);
// Extract content from all lines in the group
const lineContents = group.lines.map(line => extractContentFromLine(line));
// Check if any line has markdown
const hasMarkdown = lineContents.some(c => c.isMarkdown);
return (
<div className={`border-l-2 rounded-r my-1 py-1 px-2 group relative bg-background ${borderColor}`}>
<div className="pr-6">
{isMarkdown ? (
<div className="prose prose-sm dark:prose-invert max-w-none text-xs leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{contentToRender}
</ReactMarkdown>
<div className={`border-l-2 rounded-r my-1 py-1 px-2 group relative bg-background contain-content ${borderColor}`}>
<div className="pr-6 space-y-1">
{lineContents.map((item, index) => (
<div key={index} className="contain-layout">
{item.isMarkdown || hasMarkdown ? (
<div className="prose prose-sm dark:prose-invert max-w-none text-xs leading-relaxed contain-layout">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{item.content}
</ReactMarkdown>
</div>
) : (
<div className="text-xs whitespace-pre-wrap break-words leading-relaxed contain-layout">
{item.content}
</div>
)}
</div>
) : (
<div className="text-xs whitespace-pre-wrap break-words leading-relaxed">
{contentToRender}
</div>
)}
))}
</div>
</div>
);
}
// Memoize the OutputLineCard component to prevent unnecessary re-renders
const MemoizedOutputLineCard = memo(OutputLineCard);
// ========== Component ==========
export interface CliStreamMonitorProps {
@@ -160,11 +203,15 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
const [isUserScrolling, setIsUserScrolling] = useState(false);
const [viewMode, setViewMode] = useState<'list' | 'blocks'>('list');
// Track last output length to detect new output
const lastOutputLengthRef = useRef<Record<string, number>>({});
// Store state
const executions = useCliStreamStore((state) => state.executions);
const currentExecutionId = useCliStreamStore((state) => state.currentExecutionId);
const setCurrentExecution = useCliStreamStore((state) => state.setCurrentExecution);
const removeExecution = useCliStreamStore((state) => state.removeExecution);
const markExecutionClosedByUser = useCliStreamStore((state) => state.markExecutionClosedByUser);
// Active execution sync
const { isLoading: isSyncing, refetch } = useActiveCliExecutions(isOpen);
@@ -264,21 +311,42 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
});
invalidateActive();
}
}, [lastMessage, currentExecutionId, setCurrentExecution, invalidateActive]);
}, [lastMessage, invalidateActive]);
// Auto-scroll to bottom when new output arrives
// Auto-scroll to bottom when new output arrives (optimized - only scroll when output length changes)
useEffect(() => {
if (autoScroll && !isUserScrolling && logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [executions, autoScroll, isUserScrolling, currentExecutionId]);
if (!currentExecutionId || !autoScroll || isUserScrolling) return;
// Handle scroll to detect user scrolling
const currentExecution = executions[currentExecutionId];
if (!currentExecution) return;
const currentLength = currentExecution.output.length;
const lastLength = lastOutputLengthRef.current[currentExecutionId] || 0;
// Only scroll if new output was added
if (currentLength > lastLength) {
lastOutputLengthRef.current[currentExecutionId] = currentLength;
requestAnimationFrame(() => {
if (logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
});
}
}, [executions, currentExecutionId, autoScroll, isUserScrolling]);
// Handle scroll to detect user scrolling (with debounce for performance)
const handleScrollRef = useRef<NodeJS.Timeout | null>(null);
const handleScroll = useCallback(() => {
if (!logsContainerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = logsContainerRef.current;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
setIsUserScrolling(!isAtBottom);
if (handleScrollRef.current) {
clearTimeout(handleScrollRef.current);
}
handleScrollRef.current = setTimeout(() => {
if (!logsContainerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = logsContainerRef.current;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
setIsUserScrolling(!isAtBottom);
}, 50); // 50ms debounce
}, []);
// Scroll to bottom handler
@@ -287,6 +355,28 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
setIsUserScrolling(false);
}, []);
// Handle closing an execution tab
const handleCloseExecution = useCallback((executionId: string) => {
// Mark as closed by user so it won't be re-added by server sync
markExecutionClosedByUser(executionId);
// Remove from local state
removeExecution(executionId);
// If this was the current execution, clear current selection
if (currentExecutionId === executionId) {
const remainingIds = Object.keys(executions).filter(id => id !== executionId);
setCurrentExecution(remainingIds.length > 0 ? remainingIds[0] : null);
}
}, [markExecutionClosedByUser, removeExecution, currentExecutionId, executions, setCurrentExecution]);
// Close all executions
const handleCloseAll = useCallback(() => {
for (const id of Object.keys(executions)) {
markExecutionClosedByUser(id);
removeExecution(id);
}
setCurrentExecution(null);
}, [markExecutionClosedByUser, removeExecution, executions, setCurrentExecution]);
// ESC key to close
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
@@ -302,27 +392,67 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
return () => window.removeEventListener('keydown', handleEsc);
}, [isOpen, onClose, searchQuery]);
// Get sorted execution IDs (running first, then by start time)
const sortedExecutionIds = Object.keys(executions).sort((a, b) => {
const execA = executions[a];
const execB = executions[b];
if (execA.status === 'running' && execB.status !== 'running') return -1;
if (execA.status !== 'running' && execB.status === 'running') return 1;
return execB.startTime - execA.startTime;
});
// Cleanup scroll handler timeout on unmount
useEffect(() => {
return () => {
if (handleScrollRef.current) {
clearTimeout(handleScrollRef.current);
}
};
}, []);
// Active execution count for badge
const activeCount = Object.values(executions).filter(e => e.status === 'running').length;
// Get sorted execution IDs (memoized to avoid unnecessary recalculations)
const sortedExecutionIds = useMemo(() => {
return Object.keys(executions).sort((a, b) => {
const execA = executions[a];
const execB = executions[b];
if (execA.status === 'running' && execB.status !== 'running') return -1;
if (execA.status !== 'running' && execB.status === 'running') return 1;
return execB.startTime - execA.startTime;
});
}, [executions]);
// Current execution
const currentExecution = currentExecutionId ? executions[currentExecutionId] : null;
// Active execution count for badge (memoized)
const activeCount = useMemo(() => {
return Object.values(executions).filter(e => e.status === 'running').length;
}, [executions]);
// Filter output lines based on search
const filteredOutput = currentExecution && searchQuery
? currentExecution.output.filter(line =>
// Current execution (memoized)
const currentExecution = useMemo(() => {
return currentExecutionId ? executions[currentExecutionId] : null;
}, [currentExecutionId, executions]);
// Maximum lines to display (for performance)
const MAX_DISPLAY_LINES = 1000;
// Filter output lines based on search (memoized with limit)
const filteredOutput = useMemo(() => {
if (!currentExecution) return [];
let output = currentExecution.output;
// Apply search filter
if (searchQuery) {
output = output.filter(line =>
line.content.toLowerCase().includes(searchQuery.toLowerCase())
)
: currentExecution?.output || [];
);
}
// Limit display for performance
if (output.length > MAX_DISPLAY_LINES) {
return output.slice(-MAX_DISPLAY_LINES);
}
return output;
}, [currentExecution, searchQuery]);
// Check if output was truncated
const isOutputTruncated = currentExecution && currentExecution.output.length > MAX_DISPLAY_LINES;
// Group output lines by type (memoized for performance)
const groupedOutput = useMemo(() => {
return groupConsecutiveLinesByType(filteredOutput);
}, [filteredOutput]);
if (!isOpen) {
return null;
@@ -367,6 +497,16 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
</div>
</div>
<div className="flex items-center gap-1">
{sortedExecutionIds.length > 0 && (
<Button
variant="ghost"
size="icon"
onClick={handleCloseAll}
title="Close all executions"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
@@ -390,7 +530,7 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
onValueChange={(v) => setCurrentExecution(v || null)}
className="w-full"
>
<TabsList className="w-full h-auto flex-wrap gap-1 bg-secondary/50 p-1">
<TabsList className="w-full h-auto gap-1 bg-secondary/50 p-1 overflow-x-auto overflow-y-hidden no-scrollbar">
{sortedExecutionIds.map((id) => (
<ExecutionTab
key={id}
@@ -399,7 +539,7 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
onClick={() => setCurrentExecution(id)}
onClose={(e) => {
e.stopPropagation();
removeExecution(id);
handleCloseExecution(id);
}}
/>
))}
@@ -472,26 +612,27 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
) : (
<div
ref={logsContainerRef}
className="h-full overflow-y-auto p-3 font-mono text-xs bg-background"
className="h-full overflow-y-auto p-3 font-mono text-xs bg-background contain-strict"
onScroll={handleScroll}
>
{isOutputTruncated && (
<div className="mb-2 p-2 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded text-amber-800 dark:text-amber-200 text-xs">
Showing last {MAX_DISPLAY_LINES} lines of {currentExecution?.output.length} total lines. Use search to find specific content.
</div>
)}
{filteredOutput.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
{searchQuery ? 'No matching output found' : 'Waiting for output...'}
</div>
) : (
<div className="space-y-1">
{(() => {
// Group output lines by type
const groupedOutput = groupOutputLines(filteredOutput);
return groupedOutput.map((group, groupIndex) => (
<OutputGroupRenderer
key={`group-${group.type}-${groupIndex}`}
group={group}
onCopy={(content) => navigator.clipboard.writeText(content)}
/>
));
})()}
<div>
{groupedOutput.map((group, groupIndex) => (
<MemoizedOutputLineCard
key={`group-${group.type}-${groupIndex}`}
group={group}
onCopy={(content) => navigator.clipboard.writeText(content)}
/>
))}
<div ref={logsEndRef} />
</div>
)}