feat: Add indexing group to CodexLens environment variable schema

- Introduced a new `indexing` group in the environment variable schema with fields for AST grep usage, static graph enablement, and relationship types.
- Updated the CodexLens configuration to support new indexing features.

feat: Enhance DashboardToolbar with session and fullscreen controls

- Added props for session sidebar visibility and fullscreen mode to the DashboardToolbar component.
- Implemented handlers for toggling session sidebar and fullscreen mode.
- Updated the toolbar layout to include session sidebar toggle and fullscreen button.

refactor: Improve TerminalGrid and TerminalPane components

- Refactored GridGroupRenderer to handle pane size changes directly via store.
- Enhanced TerminalPane to remove unused file browser logic and improve layout handling.
- Updated key generation for child panes to ensure stability.

feat: Extend CodexLens API for staged Stage-2 expansion modes

- Added support for `staged_stage2_mode` in the CodexLens API, allowing for different expansion strategies.
- Updated semantic search handlers to process new stage-2 mode parameter.
- Implemented validation and handling for new stage-2 modes in the backend.

test: Add benchmarks for staged Stage-2 modes comparison

- Created a benchmark script to compare performance and results of different staged Stage-2 modes.
- Included metrics for latency, overlap, and diversity across modes.
This commit is contained in:
catlog22
2026-02-16 12:12:38 +08:00
parent 2202c2ccfd
commit de3dd044b9
13 changed files with 674 additions and 126 deletions

View File

@@ -24,7 +24,12 @@ import {
useCodexLensLspStatus,
useCodexLensSemanticSearch,
} from '@/hooks/useCodexLens';
import type { CodexLensSearchParams, CodexLensSemanticSearchMode, CodexLensFusionStrategy } from '@/lib/api';
import type {
CodexLensSearchParams,
CodexLensSemanticSearchMode,
CodexLensFusionStrategy,
CodexLensStagedStage2Mode,
} from '@/lib/api';
import { cn } from '@/lib/utils';
type SearchType = 'search' | 'search_files' | 'symbol' | 'semantic';
@@ -40,6 +45,7 @@ export function SearchTab({ enabled }: SearchTabProps) {
const [searchMode, setSearchMode] = useState<SearchMode>('dense_rerank');
const [semanticMode, setSemanticMode] = useState<CodexLensSemanticSearchMode>('fusion');
const [fusionStrategy, setFusionStrategy] = useState<CodexLensFusionStrategy>('rrf');
const [stagedStage2Mode, setStagedStage2Mode] = useState<CodexLensStagedStage2Mode>('precomputed');
const [query, setQuery] = useState('');
const [hasSearched, setHasSearched] = useState(false);
@@ -76,6 +82,7 @@ export function SearchTab({ enabled }: SearchTabProps) {
query,
mode: semanticMode,
fusion_strategy: semanticMode === 'fusion' ? fusionStrategy : undefined,
staged_stage2_mode: semanticMode === 'fusion' && fusionStrategy === 'staged' ? stagedStage2Mode : undefined,
limit: 20,
include_match_reason: true,
},
@@ -123,6 +130,11 @@ export function SearchTab({ enabled }: SearchTabProps) {
setHasSearched(false);
};
const handleStagedStage2ModeChange = (value: CodexLensStagedStage2Mode) => {
setStagedStage2Mode(value);
setHasSearched(false);
};
const handleQueryChange = (value: string) => {
setQuery(value);
setHasSearched(false);
@@ -308,6 +320,29 @@ export function SearchTab({ enabled }: SearchTabProps) {
</div>
)}
{/* Staged Stage-2 Mode - only when semantic + fusion + staged */}
{searchType === 'semantic' && semanticMode === 'fusion' && fusionStrategy === 'staged' && (
<div className="space-y-2">
<Label>{formatMessage({ id: 'codexlens.search.stagedStage2Mode' })}</Label>
<Select value={stagedStage2Mode} onValueChange={handleStagedStage2ModeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="precomputed">
{formatMessage({ id: 'codexlens.search.stagedStage2Mode.precomputed' })}
</SelectItem>
<SelectItem value="realtime">
{formatMessage({ id: 'codexlens.search.stagedStage2Mode.realtime' })}
</SelectItem>
<SelectItem value="static_global_graph">
{formatMessage({ id: 'codexlens.search.stagedStage2Mode.static_global_graph' })}
</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Query Input */}
<div className="space-y-2">
<Label htmlFor="search-query">{formatMessage({ id: 'codexlens.search.query' })}</Label>

View File

@@ -2,7 +2,7 @@
// CodexLens Environment Variable Schema
// ========================================
// TypeScript port of ENV_VAR_GROUPS from codexlens-manager.js
// Defines the 5 structured groups: embedding, reranker, concurrency, cascade, chunking
// Defines structured groups for CodexLens configuration
import type { EnvVarGroupsSchema } from '@/types/codexlens';
@@ -306,6 +306,36 @@ export const envVarGroupsSchema: EnvVarGroupsSchema = {
},
},
},
indexing: {
id: 'indexing',
labelKey: 'codexlens.envGroup.indexing',
icon: 'git-branch',
vars: {
CODEXLENS_USE_ASTGREP: {
key: 'CODEXLENS_USE_ASTGREP',
labelKey: 'codexlens.envField.useAstGrep',
type: 'checkbox',
default: 'false',
settingsPath: 'parsing.use_astgrep',
},
CODEXLENS_STATIC_GRAPH_ENABLED: {
key: 'CODEXLENS_STATIC_GRAPH_ENABLED',
labelKey: 'codexlens.envField.staticGraphEnabled',
type: 'checkbox',
default: 'false',
settingsPath: 'indexing.static_graph_enabled',
},
CODEXLENS_STATIC_GRAPH_RELATIONSHIP_TYPES: {
key: 'CODEXLENS_STATIC_GRAPH_RELATIONSHIP_TYPES',
labelKey: 'codexlens.envField.staticGraphRelationshipTypes',
type: 'text',
placeholder: 'imports,inherits,calls',
default: 'imports,inherits',
settingsPath: 'indexing.static_graph_relationship_types',
showWhen: (env) => env['CODEXLENS_STATIC_GRAPH_ENABLED'] === 'true',
},
},
},
chunking: {
id: 'chunking',
labelKey: 'codexlens.envGroup.chunking',

View File

@@ -21,6 +21,9 @@ import {
Zap,
Settings,
Loader2,
Folder,
Maximize2,
Minimize2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/Badge';
@@ -57,6 +60,14 @@ interface DashboardToolbarProps {
isFileSidebarOpen?: boolean;
/** Callback to toggle file sidebar */
onToggleFileSidebar?: () => void;
/** Whether the session sidebar is open */
isSessionSidebarOpen?: boolean;
/** Callback to toggle session sidebar */
onToggleSessionSidebar?: () => void;
/** Whether fullscreen mode is active */
isFullscreen?: boolean;
/** Callback to toggle fullscreen mode */
onToggleFullscreen?: () => void;
}
// ========== Layout Presets ==========
@@ -83,7 +94,7 @@ const LAUNCH_COMMANDS: Record<CliTool, Record<LaunchMode, string>> = {
// ========== Component ==========
export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen, onToggleFileSidebar }: DashboardToolbarProps) {
export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen, onToggleFileSidebar, isSessionSidebarOpen, onToggleSessionSidebar, isFullscreen, onToggleFullscreen }: DashboardToolbarProps) {
const { formatMessage } = useIntl();
// Issues count
@@ -117,17 +128,30 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
// Launch CLI handlers
const projectPath = useWorkflowStore(selectProjectPath);
const focusedPaneId = useTerminalGridStore(selectTerminalGridFocusedPaneId);
const panes = useTerminalGridStore((s) => s.panes);
const createSessionAndAssign = useTerminalGridStore((s) => s.createSessionAndAssign);
const [isCreating, setIsCreating] = useState(false);
const [selectedTool, setSelectedTool] = useState<CliTool>('gemini');
const [launchMode, setLaunchMode] = useState<LaunchMode>('yolo');
const [isConfigOpen, setIsConfigOpen] = useState(false);
// Helper to get or create a focused pane
const getOrCreateFocusedPane = useCallback(() => {
if (focusedPaneId) return focusedPaneId;
// No focused pane - reset layout to create a single pane
resetLayout('single');
// Get the new focused pane id from store
return useTerminalGridStore.getState().focusedPaneId;
}, [focusedPaneId]);
const handleQuickCreate = useCallback(async () => {
if (!focusedPaneId || !projectPath) return;
if (!projectPath) return;
setIsCreating(true);
try {
const created = await createSessionAndAssign(focusedPaneId, {
const targetPaneId = getOrCreateFocusedPane();
if (!targetPaneId) return;
const created = await createSessionAndAssign(targetPaneId, {
workingDir: projectPath,
preferredShell: 'bash',
tool: selectedTool,
@@ -146,18 +170,21 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
} finally {
setIsCreating(false);
}
}, [focusedPaneId, projectPath, createSessionAndAssign, selectedTool, launchMode]);
}, [projectPath, createSessionAndAssign, selectedTool, launchMode, getOrCreateFocusedPane]);
const handleConfigure = useCallback(() => {
setIsConfigOpen(true);
}, []);
const handleCreateConfiguredSession = useCallback(async (config: CliSessionConfig) => {
if (!focusedPaneId || !projectPath) throw new Error('No focused pane or project path');
if (!projectPath) throw new Error('No project path');
setIsCreating(true);
try {
const targetPaneId = getOrCreateFocusedPane();
if (!targetPaneId) throw new Error('Failed to create pane');
const created = await createSessionAndAssign(
focusedPaneId,
targetPaneId,
{
workingDir: config.workingDir || projectPath,
preferredShell: config.preferredShell,
@@ -182,7 +209,7 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
} finally {
setIsCreating(false);
}
}, [focusedPaneId, projectPath, createSessionAndAssign]);
}, [projectPath, createSessionAndAssign, getOrCreateFocusedPane]);
return (
<>
@@ -254,7 +281,7 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleQuickCreate}
disabled={isCreating || !projectPath || !focusedPaneId}
disabled={isCreating || !projectPath}
className="gap-2"
>
<Zap className="w-4 h-4" />
@@ -263,7 +290,7 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleConfigure}
disabled={isCreating || !projectPath || !focusedPaneId}
disabled={isCreating || !projectPath}
className="gap-2"
>
<Settings className="w-4 h-4" />
@@ -275,6 +302,17 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
{/* Separator */}
<div className="w-px h-5 bg-border mx-1" />
{/* Session sidebar toggle */}
<ToolbarButton
icon={Folder}
label={formatMessage({ id: 'terminalDashboard.toolbar.sessions', defaultMessage: 'Sessions' })}
isActive={isSessionSidebarOpen ?? true}
onClick={() => onToggleSessionSidebar?.()}
/>
{/* Separator */}
<div className="w-px h-5 bg-border mx-1" />
{/* Panel toggle buttons */}
<ToolbarButton
icon={AlertCircle}
@@ -322,6 +360,30 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
</button>
))}
{/* Separator */}
<div className="w-px h-5 bg-border mx-1" />
{/* Fullscreen toggle */}
<button
onClick={onToggleFullscreen}
className={cn(
'p-1.5 rounded transition-colors',
isFullscreen
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
title={isFullscreen
? formatMessage({ id: 'terminalDashboard.toolbar.exitFullscreen', defaultMessage: 'Exit Fullscreen' })
: formatMessage({ id: 'terminalDashboard.toolbar.fullscreen', defaultMessage: 'Fullscreen' })
}
>
{isFullscreen ? (
<Minimize2 className="w-3.5 h-3.5" />
) : (
<Maximize2 className="w-3.5 h-3.5" />
)}
</button>
{/* Right-aligned title */}
<span className="ml-auto text-xs text-muted-foreground font-medium">
{formatMessage({ id: 'terminalDashboard.page.title' })}

View File

@@ -23,19 +23,20 @@ import { TerminalPane } from './TerminalPane';
interface GridGroupRendererProps {
group: AllotmentLayoutGroup;
minSize: number;
onSizeChange: (sizes: number[]) => void;
depth?: number;
}
// ========== Recursive Group Renderer ==========
function GridGroupRenderer({ group, minSize, onSizeChange }: GridGroupRendererProps) {
function GridGroupRenderer({ group, minSize, depth = 0 }: GridGroupRendererProps) {
const panes = useTerminalGridStore(selectTerminalGridPanes);
const updateLayoutSizes = useTerminalGridStore((s) => s.updateLayoutSizes);
const handleChange = useCallback(
(sizes: number[]) => {
onSizeChange(sizes);
updateLayoutSizes(sizes);
},
[onSizeChange]
[updateLayoutSizes]
);
const validChildren = useMemo(() => {
@@ -51,22 +52,27 @@ function GridGroupRenderer({ group, minSize, onSizeChange }: GridGroupRendererPr
return null;
}
// Generate stable key based on children
const groupKey = useMemo(() => {
return validChildren.map(c => isPaneId(c) ? c : 'group').join('-');
}, [validChildren]);
return (
<Allotment
key={groupKey}
vertical={group.direction === 'vertical'}
defaultSizes={group.sizes}
onChange={handleChange}
className="h-full"
>
{validChildren.map((child, index) => (
<Allotment.Pane key={isPaneId(child) ? child : `group-${index}`} minSize={minSize}>
<Allotment.Pane key={isPaneId(child) ? child : `group-${depth}-${index}`} minSize={minSize}>
{isPaneId(child) ? (
<TerminalPane paneId={child} />
) : (
<GridGroupRenderer
group={child}
minSize={minSize}
onSizeChange={onSizeChange}
depth={depth + 1}
/>
)}
</Allotment.Pane>
@@ -80,14 +86,6 @@ function GridGroupRenderer({ group, minSize, onSizeChange }: GridGroupRendererPr
export function TerminalGrid({ className }: { className?: string }) {
const layout = useTerminalGridStore(selectTerminalGridLayout);
const panes = useTerminalGridStore(selectTerminalGridPanes);
const updateLayoutSizes = useTerminalGridStore((s) => s.updateLayoutSizes);
const handleSizeChange = useCallback(
(sizes: number[]) => {
updateLayoutSizes(sizes);
},
[updateLayoutSizes]
);
const content = useMemo(() => {
if (!layout.children || layout.children.length === 0) {
@@ -105,10 +103,10 @@ export function TerminalGrid({ className }: { className?: string }) {
<GridGroupRenderer
group={layout}
minSize={150}
onSizeChange={handleSizeChange}
depth={0}
/>
);
}, [layout, panes, handleSizeChange]);
}, [layout, panes]);
return (
<div className={cn('h-full w-full overflow-hidden bg-background', className)}>

View File

@@ -4,13 +4,13 @@
// Single terminal pane = PaneToolbar + content area.
// Content can be terminal output or file preview based on displayMode.
// Renders within the TerminalGrid recursive layout.
// File preview is triggered from right sidebar FileSidebarPanel.
import { useCallback, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import {
SplitSquareHorizontal,
SplitSquareVertical,
FolderOpen,
Eraser,
AlertTriangle,
X,
@@ -25,7 +25,6 @@ import {
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { TerminalInstance } from './TerminalInstance';
import { FloatingFileBrowser } from './FloatingFileBrowser';
import { FilePreview } from '@/components/shared/FilePreview';
import {
useTerminalGridStore,
@@ -43,7 +42,6 @@ import {
} from '@/stores/issueQueueIntegrationStore';
import { useCliSessionStore } from '@/stores/cliSessionStore';
import { getAllPaneIds } from '@/lib/layout-utils';
import { sendCliSessionText } from '@/lib/api';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { useFileContent } from '@/hooks/useFileExplorer';
import type { PaneId } from '@/stores/viewerStore';
@@ -89,8 +87,6 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
const isFileMode = displayMode === 'file' && filePath;
const projectPath = useWorkflowStore(selectProjectPath);
const [isFileBrowserOpen, setIsFileBrowserOpen] = useState(false);
const [initialFileBrowserPath, setInitialFileBrowserPath] = useState<string | null>(null);
// Session data
const groups = useSessionManagerStore(selectGroups);
@@ -168,25 +164,6 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
}
}, [paneId, sessionId, assignSession]);
const handleOpenFileBrowser = useCallback(() => {
setInitialFileBrowserPath(null);
setIsFileBrowserOpen(true);
}, []);
const handleRevealPath = useCallback((path: string) => {
setInitialFileBrowserPath(path);
setIsFileBrowserOpen(true);
}, []);
const handleInsertPath = useCallback((path: string) => {
if (!sessionId) return;
sendCliSessionText(
sessionId,
{ text: path, appendNewline: false },
projectPath ?? undefined
).catch((err) => console.error('[TerminalPane] insert path failed:', err));
}, [sessionId, projectPath]);
const handleRestart = useCallback(async () => {
if (!sessionId || isRestarting) return;
setIsRestarting(true);
@@ -229,9 +206,9 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
onClick={handleFocus}
>
{/* PaneToolbar */}
<div className="flex items-center gap-1 px-2 py-1 border-b border-border bg-muted/30 shrink-0">
<div className="flex items-center justify-between gap-1 px-2 py-1 border-b border-border bg-muted/30 shrink-0 overflow-hidden">
{/* Left: Session selector + status (or file path in file mode) */}
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<div className="flex items-center gap-1.5 min-w-0 overflow-hidden">
{isFileMode ? (
// File mode header
<>
@@ -255,13 +232,13 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
className={cn('w-2 h-2 rounded-full shrink-0', statusDotStyles[status])}
/>
)}
<div className="relative min-w-0 flex-1 max-w-[180px]">
<div className="relative min-w-0 overflow-hidden">
<select
value={sessionId ?? ''}
onChange={handleSessionChange}
className={cn(
'w-full text-xs bg-transparent border-none outline-none cursor-pointer',
'appearance-none pr-5 truncate',
'text-xs bg-transparent border-none outline-none cursor-pointer',
'appearance-none pr-4 max-w-[140px] truncate',
!sessionId && 'text-muted-foreground'
)}
>
@@ -282,7 +259,7 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
{/* Center: Linked issue badge */}
{linkedIssueId && !isFileMode && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-primary/10 text-primary shrink-0">
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-primary/10 text-primary shrink-0 hidden sm:inline-flex">
{linkedIssueId}
</span>
)}
@@ -357,21 +334,6 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
</button>
</>
)}
{!isFileMode && (
<button
onClick={handleOpenFileBrowser}
disabled={!projectPath}
className={cn(
'p-1 rounded hover:bg-muted transition-colors',
projectPath
? 'text-muted-foreground hover:text-foreground'
: 'text-muted-foreground/40 cursor-not-allowed'
)}
title={formatMessage({ id: 'terminalDashboard.fileBrowser.open' })}
>
<FolderOpen className="w-3.5 h-3.5" />
</button>
)}
{alertCount > 0 && !isFileMode && (
<span className="flex items-center gap-0.5 px-1 text-destructive">
<AlertTriangle className="w-3 h-3" />
@@ -406,7 +368,7 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
) : sessionId ? (
// Terminal mode with session
<div className="flex-1 min-h-0">
<TerminalInstance sessionId={sessionId} onRevealPath={handleRevealPath} />
<TerminalInstance sessionId={sessionId} />
</div>
) : (
// Empty terminal state
@@ -422,17 +384,6 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
</div>
</div>
)}
<FloatingFileBrowser
isOpen={isFileBrowserOpen}
onClose={() => {
setIsFileBrowserOpen(false);
setInitialFileBrowserPath(null);
}}
rootPath={projectPath ?? '/'}
onInsertPath={sessionId ? handleInsertPath : undefined}
initialSelectedPath={initialFileBrowserPath}
/>
</div>
);
}

View File

@@ -5015,12 +5015,14 @@ export interface CodexLensLspStatusResponse {
*/
export type CodexLensSemanticSearchMode = 'fusion' | 'vector' | 'structural';
export type CodexLensFusionStrategy = 'rrf' | 'staged' | 'binary' | 'hybrid' | 'dense_rerank';
export type CodexLensStagedStage2Mode = 'precomputed' | 'realtime' | 'static_global_graph';
export interface CodexLensSemanticSearchParams {
query: string;
path?: string;
mode?: CodexLensSemanticSearchMode;
fusion_strategy?: CodexLensFusionStrategy;
staged_stage2_mode?: CodexLensStagedStage2Mode;
vector_weight?: number;
structural_weight?: number;
keyword_weight?: number;

View File

@@ -239,6 +239,10 @@
"fusionStrategy.binary": "Binary",
"fusionStrategy.hybrid": "Hybrid",
"fusionStrategy.staged": "Staged",
"stagedStage2Mode": "Staged Stage 2",
"stagedStage2Mode.precomputed": "Precomputed (graph_neighbors)",
"stagedStage2Mode.realtime": "Realtime (LSP)",
"stagedStage2Mode.static_global_graph": "Static Global Graph",
"lspStatus": "LSP Status",
"lspAvailable": "Semantic search available",
"lspUnavailable": "Semantic search unavailable",
@@ -285,6 +289,7 @@
"reranker": "Reranker",
"concurrency": "Concurrency",
"cascade": "Cascade Search",
"indexing": "Indexing",
"chunking": "Chunking"
},
"envField": {
@@ -305,6 +310,9 @@
"searchStrategy": "Search Strategy",
"coarseK": "Coarse K",
"fineK": "Fine K",
"useAstGrep": "Use ast-grep",
"staticGraphEnabled": "Static Graph",
"staticGraphRelationshipTypes": "Relationship Types",
"stripComments": "Strip Comments",
"stripDocstrings": "Strip Docstrings",
"testFilePenalty": "Test File Penalty",

View File

@@ -239,6 +239,10 @@
"fusionStrategy.binary": "Binary",
"fusionStrategy.hybrid": "Hybrid",
"fusionStrategy.staged": "Staged",
"stagedStage2Mode": "Stage 2 扩展",
"stagedStage2Mode.precomputed": "预计算 (graph_neighbors)",
"stagedStage2Mode.realtime": "实时 (LSP)",
"stagedStage2Mode.static_global_graph": "静态全局图",
"lspStatus": "LSP 状态",
"lspAvailable": "语义搜索可用",
"lspUnavailable": "语义搜索不可用",
@@ -285,6 +289,7 @@
"reranker": "重排序",
"concurrency": "并发",
"cascade": "级联搜索",
"indexing": "索引与解析",
"chunking": "分块"
},
"envField": {
@@ -305,6 +310,9 @@
"searchStrategy": "搜索策略",
"coarseK": "粗筛 K 值",
"fineK": "精筛 K 值",
"useAstGrep": "使用 ast-grep",
"staticGraphEnabled": "启用静态图",
"staticGraphRelationshipTypes": "关系类型",
"stripComments": "去除注释",
"stripDocstrings": "去除文档字符串",
"testFilePenalty": "测试文件惩罚",

View File

@@ -7,6 +7,7 @@
// Right sidebar: FileSidebarPanel (file tree, resizable)
// Top: DashboardToolbar with panel toggles and layout presets
// Floating panels: Issues, Queue, Inspector (overlay, mutually exclusive)
// Fullscreen mode: Hides all sidebars for maximum terminal space
import { useState, useCallback } from 'react';
import { useIntl } from 'react-intl';
@@ -30,6 +31,8 @@ export function TerminalDashboardPage() {
const { formatMessage } = useIntl();
const [activePanel, setActivePanel] = useState<PanelId | null>(null);
const [isFileSidebarOpen, setIsFileSidebarOpen] = useState(true);
const [isSessionSidebarOpen, setIsSessionSidebarOpen] = useState(true);
const [isFullscreen, setIsFullscreen] = useState(false);
const projectPath = useWorkflowStore(selectProjectPath);
@@ -41,8 +44,17 @@ export function TerminalDashboardPage() {
setActivePanel(null);
}, []);
const toggleFullscreen = useCallback(() => {
setIsFullscreen((prev) => !prev);
}, []);
// In fullscreen mode, hide all sidebars and panels
const showSessionSidebar = isSessionSidebarOpen && !isFullscreen;
const showFileSidebar = isFileSidebarOpen && !isFullscreen;
const showFloatingPanels = !isFullscreen;
return (
<div className="-m-4 md:-m-6 flex flex-col h-[calc(100vh-56px)] overflow-hidden">
<div className={`flex flex-col overflow-hidden ${isFullscreen ? 'h-screen -m-0' : 'h-[calc(100vh-56px)] -m-4 md:-m-6'}`}>
<AssociationHighlightProvider>
{/* Global toolbar */}
<DashboardToolbar
@@ -50,30 +62,36 @@ export function TerminalDashboardPage() {
onTogglePanel={togglePanel}
isFileSidebarOpen={isFileSidebarOpen}
onToggleFileSidebar={() => setIsFileSidebarOpen((prev) => !prev)}
isSessionSidebarOpen={isSessionSidebarOpen}
onToggleSessionSidebar={() => setIsSessionSidebarOpen((prev) => !prev)}
isFullscreen={isFullscreen}
onToggleFullscreen={toggleFullscreen}
/>
{/* Main content with three-column layout */}
<div className="flex-1 min-h-0">
<Allotment>
{/* Fixed session sidebar (240px) */}
<Allotment.Pane preferredSize={240} minSize={180} maxSize={320}>
<div className="h-full flex flex-col border-r border-border">
<div className="flex-1 min-h-0 overflow-y-auto">
<SessionGroupTree />
<Allotment className="h-full">
{/* Session sidebar (conditional) */}
{showSessionSidebar && (
<Allotment.Pane preferredSize={240} minSize={180} maxSize={320}>
<div className="h-full flex flex-col border-r border-border">
<div className="flex-1 min-h-0 overflow-y-auto">
<SessionGroupTree />
</div>
<div className="shrink-0">
<AgentList />
</div>
</div>
<div className="shrink-0">
<AgentList />
</div>
</div>
</Allotment.Pane>
</Allotment.Pane>
)}
{/* Terminal grid (flexible) */}
<Allotment.Pane minSize={300}>
<Allotment.Pane preferredSize={-1} minSize={300}>
<TerminalGrid />
</Allotment.Pane>
{/* File sidebar (conditional, default 280px) */}
{isFileSidebarOpen && (
{showFileSidebar && (
<Allotment.Pane preferredSize={280} minSize={200} maxSize={400}>
<FileSidebarPanel
rootPath={projectPath ?? '/'}
@@ -86,35 +104,39 @@ export function TerminalDashboardPage() {
</div>
{/* Floating panels (conditional, overlay) */}
<FloatingPanel
isOpen={activePanel === 'issues'}
onClose={closePanel}
title={formatMessage({ id: 'terminalDashboard.toolbar.issues' })}
side="left"
width={380}
>
<IssuePanel />
</FloatingPanel>
{showFloatingPanels && (
<>
<FloatingPanel
isOpen={activePanel === 'issues'}
onClose={closePanel}
title={formatMessage({ id: 'terminalDashboard.toolbar.issues' })}
side="left"
width={380}
>
<IssuePanel />
</FloatingPanel>
<FloatingPanel
isOpen={activePanel === 'queue'}
onClose={closePanel}
title={formatMessage({ id: 'terminalDashboard.toolbar.queue' })}
side="right"
width={400}
>
<QueuePanel />
</FloatingPanel>
<FloatingPanel
isOpen={activePanel === 'queue'}
onClose={closePanel}
title={formatMessage({ id: 'terminalDashboard.toolbar.queue' })}
side="right"
width={400}
>
<QueuePanel />
</FloatingPanel>
<FloatingPanel
isOpen={activePanel === 'inspector'}
onClose={closePanel}
title={formatMessage({ id: 'terminalDashboard.toolbar.inspector' })}
side="right"
width={360}
>
<InspectorContent />
</FloatingPanel>
<FloatingPanel
isOpen={activePanel === 'inspector'}
onClose={closePanel}
title={formatMessage({ id: 'terminalDashboard.toolbar.inspector' })}
side="right"
width={360}
>
<InspectorContent />
</FloatingPanel>
</>
)}
</AssociationHighlightProvider>
</div>
);

View File

@@ -1187,6 +1187,7 @@ except Exception as e:
path: projectPath,
mode = 'fusion',
fusion_strategy = 'rrf',
staged_stage2_mode,
vector_weight = 0.5,
structural_weight = 0.3,
keyword_weight = 0.2,
@@ -1198,6 +1199,7 @@ except Exception as e:
path?: unknown;
mode?: unknown;
fusion_strategy?: unknown;
staged_stage2_mode?: unknown;
vector_weight?: unknown;
structural_weight?: unknown;
keyword_weight?: unknown;
@@ -1215,6 +1217,23 @@ except Exception as e:
const resolvedMode = typeof mode === 'string' && ['fusion', 'vector', 'structural'].includes(mode) ? mode : 'fusion';
const resolvedStrategy = typeof fusion_strategy === 'string' &&
['rrf', 'staged', 'binary', 'hybrid', 'dense_rerank'].includes(fusion_strategy) ? fusion_strategy : 'rrf';
let resolvedStage2Mode: 'precomputed' | 'realtime' | 'static_global_graph' | undefined;
if (resolvedStrategy === 'staged' && typeof staged_stage2_mode === 'string') {
const stage2 = staged_stage2_mode.trim().toLowerCase();
if (stage2.length > 0) {
if (stage2 === 'live') {
resolvedStage2Mode = 'realtime';
} else if (stage2 === 'precomputed' || stage2 === 'realtime' || stage2 === 'static_global_graph') {
resolvedStage2Mode = stage2;
} else {
return {
success: false,
error: `Invalid staged_stage2_mode: ${stage2}. Must be one of: precomputed, realtime, static_global_graph`,
status: 400,
};
}
}
}
const resolvedVectorWeight = typeof vector_weight === 'number' ? vector_weight : 0.5;
const resolvedStructuralWeight = typeof structural_weight === 'number' ? structural_weight : 0.3;
const resolvedKeywordWeight = typeof keyword_weight === 'number' ? keyword_weight : 0.2;
@@ -1234,6 +1253,10 @@ except Exception as e:
include_match_reason: resolvedIncludeReason,
};
if (resolvedStage2Mode) {
apiArgs.staged_stage2_mode = resolvedStage2Mode;
}
if (Array.isArray(kind_filter) && kind_filter.length > 0) {
apiArgs.kind_filter = kind_filter;
}

View File

@@ -0,0 +1,391 @@
#!/usr/bin/env python
"""Compare staged cascade Stage-2 modes (precomputed vs realtime vs static graph).
This benchmark compares the *same* staged cascade strategy with different Stage-2
expansion sources:
1) precomputed: per-dir `graph_neighbors` expansion (fast, index-local)
2) realtime: live LSP graph expansion (contextual, requires LSP availability)
3) static_global_graph: global_relationships expansion (project-wide, requires static graph indexing)
Because most repos do not have ground-truth labels, this script reports:
- latency statistics per mode
- top-k overlap metrics (Jaccard + RBO) between modes
- diversity proxies (unique files/dirs)
- staged pipeline stage stats (when present)
Usage:
python benchmarks/compare_staged_stage2_modes.py --source ./src
python benchmarks/compare_staged_stage2_modes.py --queries-file benchmarks/queries.txt
"""
from __future__ import annotations
import argparse
import gc
import json
import os
import re
import statistics
import sys
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple
# Add src to path (match other benchmark scripts)
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from codexlens.config import Config
from codexlens.search.chain_search import ChainSearchEngine
from codexlens.storage.path_mapper import PathMapper
from codexlens.storage.registry import RegistryStore
DEFAULT_QUERIES = [
"class Config",
"def search",
"LspBridge",
"graph expansion",
"static graph relationships",
"clustering strategy",
"error handling",
]
VALID_STAGE2_MODES = ("precomputed", "realtime", "static_global_graph")
def _now_ms() -> float:
return time.perf_counter() * 1000.0
def _normalize_path_key(path: str) -> str:
"""Normalize file paths for overlap/dedup metrics (Windows-safe)."""
try:
p = Path(path)
if str(p) and (p.is_absolute() or re.match(r"^[A-Za-z]:", str(p))):
norm = str(p.resolve())
else:
norm = str(p)
except Exception:
norm = path
norm = norm.replace("/", "\\")
if os.name == "nt":
norm = norm.lower()
return norm
def _extract_stage_stats(errors: List[str]) -> Optional[Dict[str, Any]]:
"""Extract STAGE_STATS JSON blob from SearchStats.errors."""
for item in errors or []:
if not isinstance(item, str):
continue
if not item.startswith("STAGE_STATS:"):
continue
payload = item[len("STAGE_STATS:") :]
try:
return json.loads(payload)
except Exception:
return None
return None
def jaccard_topk(a: List[str], b: List[str]) -> float:
sa, sb = set(a), set(b)
if not sa and not sb:
return 1.0
if not sa or not sb:
return 0.0
return len(sa & sb) / len(sa | sb)
def rbo(a: List[str], b: List[str], p: float = 0.9) -> float:
"""Rank-biased overlap for two ranked lists."""
if p <= 0.0 or p >= 1.0:
raise ValueError("p must be in (0, 1)")
if not a and not b:
return 1.0
depth = max(len(a), len(b))
seen_a: set[str] = set()
seen_b: set[str] = set()
score = 0.0
for d in range(1, depth + 1):
if d <= len(a):
seen_a.add(a[d - 1])
if d <= len(b):
seen_b.add(b[d - 1])
overlap = len(seen_a & seen_b)
score += (overlap / d) * ((1.0 - p) * (p ** (d - 1)))
return score
def _unique_parent_dirs(paths: Iterable[str]) -> int:
dirs = set()
for p in paths:
try:
dirs.add(str(Path(p).parent))
except Exception:
continue
return len(dirs)
def _load_queries(path: Optional[Path], inline: Optional[List[str]]) -> List[str]:
if inline:
return [q.strip() for q in inline if isinstance(q, str) and q.strip()]
if path:
if not path.exists():
raise SystemExit(f"Queries file does not exist: {path}")
raw = path.read_text(encoding="utf-8", errors="ignore")
queries = [line.strip() for line in raw.splitlines() if line.strip() and not line.strip().startswith("#")]
return queries
return list(DEFAULT_QUERIES)
@dataclass
class RunDetail:
stage2_mode: str
query: str
latency_ms: float
num_results: int
topk_paths: List[str]
stage_stats: Optional[Dict[str, Any]] = None
error: Optional[str] = None
@dataclass
class PairwiseCompare:
query: str
mode_a: str
mode_b: str
jaccard_topk: float
rbo_topk: float
a_unique_files_topk: int
b_unique_files_topk: int
a_unique_dirs_topk: int
b_unique_dirs_topk: int
def _run_once(
engine: ChainSearchEngine,
config: Config,
query: str,
source_path: Path,
*,
stage2_mode: str,
k: int,
coarse_k: int,
) -> RunDetail:
if stage2_mode not in VALID_STAGE2_MODES:
raise ValueError(f"Invalid stage2_mode: {stage2_mode}")
# Mutate config for this run; ChainSearchEngine reads config fields per-call.
config.staged_stage2_mode = stage2_mode
gc.collect()
start_ms = _now_ms()
try:
result = engine.cascade_search(
query=query,
source_path=source_path,
k=k,
coarse_k=coarse_k,
strategy="staged",
)
latency_ms = _now_ms() - start_ms
paths_raw = [r.path for r in (result.results or []) if getattr(r, "path", None)]
paths = [_normalize_path_key(p) for p in paths_raw]
topk: List[str] = []
seen: set[str] = set()
for p in paths:
if p in seen:
continue
seen.add(p)
topk.append(p)
if len(topk) >= k:
break
stage_stats = None
try:
stage_stats = _extract_stage_stats(getattr(result.stats, "errors", []) or [])
except Exception:
stage_stats = None
return RunDetail(
stage2_mode=stage2_mode,
query=query,
latency_ms=latency_ms,
num_results=len(result.results or []),
topk_paths=topk,
stage_stats=stage_stats,
error=None,
)
except Exception as exc:
return RunDetail(
stage2_mode=stage2_mode,
query=query,
latency_ms=_now_ms() - start_ms,
num_results=0,
topk_paths=[],
stage_stats=None,
error=str(exc),
)
def main() -> None:
parser = argparse.ArgumentParser(description="Compare staged Stage-2 expansion modes.")
parser.add_argument("--source", type=Path, default=Path.cwd(), help="Project path to search")
parser.add_argument("--queries-file", type=Path, default=None, help="Optional newline-delimited queries file")
parser.add_argument("--queries", nargs="*", default=None, help="Inline queries (overrides queries-file)")
parser.add_argument("--k", type=int, default=20, help="Top-k to evaluate")
parser.add_argument("--coarse-k", type=int, default=100, help="Stage-1 coarse_k")
parser.add_argument(
"--stage2-modes",
nargs="*",
default=list(VALID_STAGE2_MODES),
help="Stage-2 modes to compare",
)
parser.add_argument("--warmup", type=int, default=0, help="Warmup iterations per mode")
parser.add_argument(
"--output",
type=Path,
default=Path(__file__).parent / "results" / "staged_stage2_modes.json",
help="Output JSON path",
)
args = parser.parse_args()
if not args.source.exists():
raise SystemExit(f"Source path does not exist: {args.source}")
stage2_modes = [str(m).strip().lower() for m in (args.stage2_modes or []) if str(m).strip()]
for m in stage2_modes:
if m not in VALID_STAGE2_MODES:
raise SystemExit(f"Invalid --stage2-modes entry: {m} (valid: {', '.join(VALID_STAGE2_MODES)})")
queries = _load_queries(args.queries_file, args.queries)
if not queries:
raise SystemExit("No queries to run")
# Match CLI behavior: load settings + apply global/workspace .env overrides.
config = Config.load()
config.cascade_strategy = "staged"
config.enable_staged_rerank = True
config.embedding_use_gpu = False # stability on some Windows setups
registry = RegistryStore()
registry.initialize()
mapper = PathMapper()
engine = ChainSearchEngine(registry=registry, mapper=mapper, config=config)
try:
# Warmup
if args.warmup > 0:
warm_query = queries[0]
for mode in stage2_modes:
for _ in range(args.warmup):
try:
_run_once(
engine,
config,
warm_query,
args.source,
stage2_mode=mode,
k=min(args.k, 5),
coarse_k=min(args.coarse_k, 50),
)
except Exception:
pass
per_query: Dict[str, Dict[str, RunDetail]] = {}
runs: List[RunDetail] = []
comparisons: List[PairwiseCompare] = []
for i, query in enumerate(queries, start=1):
print(f"[{i}/{len(queries)}] {query}")
per_query[query] = {}
for mode in stage2_modes:
detail = _run_once(
engine,
config,
query,
args.source,
stage2_mode=mode,
k=args.k,
coarse_k=args.coarse_k,
)
per_query[query][mode] = detail
runs.append(detail)
# Pairwise overlaps for this query
for a_idx in range(len(stage2_modes)):
for b_idx in range(a_idx + 1, len(stage2_modes)):
mode_a = stage2_modes[a_idx]
mode_b = stage2_modes[b_idx]
a = per_query[query][mode_a]
b = per_query[query][mode_b]
comparisons.append(
PairwiseCompare(
query=query,
mode_a=mode_a,
mode_b=mode_b,
jaccard_topk=jaccard_topk(a.topk_paths, b.topk_paths),
rbo_topk=rbo(a.topk_paths, b.topk_paths, p=0.9),
a_unique_files_topk=len(set(a.topk_paths)),
b_unique_files_topk=len(set(b.topk_paths)),
a_unique_dirs_topk=_unique_parent_dirs(a.topk_paths),
b_unique_dirs_topk=_unique_parent_dirs(b.topk_paths),
)
)
def _latencies(details: List[RunDetail]) -> List[float]:
return [d.latency_ms for d in details if not d.error]
mode_summaries: Dict[str, Dict[str, Any]] = {}
for mode in stage2_modes:
mode_runs = [r for r in runs if r.stage2_mode == mode]
lat = _latencies(mode_runs)
mode_summaries[mode] = {
"success": sum(1 for r in mode_runs if not r.error),
"avg_latency_ms": statistics.mean(lat) if lat else 0.0,
"p50_latency_ms": statistics.median(lat) if lat else 0.0,
"p95_latency_ms": statistics.quantiles(lat, n=20)[18] if len(lat) >= 2 else (lat[0] if lat else 0.0),
}
summary = {
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
"source": str(args.source),
"k": args.k,
"coarse_k": args.coarse_k,
"query_count": len(queries),
"stage2_modes": stage2_modes,
"modes": mode_summaries,
"avg_pairwise_jaccard_topk": statistics.mean([c.jaccard_topk for c in comparisons]) if comparisons else 0.0,
"avg_pairwise_rbo_topk": statistics.mean([c.rbo_topk for c in comparisons]) if comparisons else 0.0,
}
args.output.parent.mkdir(parents=True, exist_ok=True)
payload = {
"summary": summary,
"runs": [asdict(r) for r in runs],
"comparisons": [asdict(c) for c in comparisons],
}
args.output.write_text(json.dumps(payload, indent=2), encoding="utf-8")
print(f"\nSaved: {args.output}")
finally:
try:
engine.close()
except Exception as exc:
print(f"WARNING engine.close() failed: {exc!r}", file=sys.stderr)
try:
registry.close()
except Exception as exc:
print(f"WARNING registry.close() failed: {exc!r}", file=sys.stderr)
if __name__ == "__main__":
main()

View File

@@ -24,6 +24,7 @@ def semantic_search(
structural_weight: float = 0.3,
keyword_weight: float = 0.2,
fusion_strategy: str = "rrf",
staged_stage2_mode: Optional[str] = None,
kind_filter: Optional[List[str]] = None,
limit: int = 20,
include_match_reason: bool = False,
@@ -50,6 +51,10 @@ def semantic_search(
- binary: Binary rerank cascade -> binary_cascade_search
- hybrid: Binary rerank cascade (backward compat) -> binary_rerank_cascade_search
- dense_rerank: Dense rerank cascade -> dense_rerank_cascade_search
staged_stage2_mode: Optional override for staged Stage-2 expansion mode
- precomputed: GraphExpander over per-dir graph_neighbors (default)
- realtime: Live LSP expansion (requires LSP availability)
- static_global_graph: GlobalGraphExpander over global_relationships
kind_filter: Symbol type filter (e.g., ["function", "class"])
limit: Max return count (default 20)
include_match_reason: Generate match reason (heuristic, not LLM)
@@ -97,6 +102,17 @@ def semantic_search(
# Load config
config = Config.load()
# Optional per-call override for staged cascade Stage-2 mode.
if staged_stage2_mode:
stage2 = str(staged_stage2_mode).strip().lower()
if stage2 in {"live"}:
stage2 = "realtime"
valid_stage2 = {"precomputed", "realtime", "static_global_graph"}
if stage2 in valid_stage2:
config.staged_stage2_mode = stage2
else:
logger.debug("Ignoring invalid staged_stage2_mode: %r", staged_stage2_mode)
# Get or create registry and mapper
try:
registry = RegistryStore.default()

View File

@@ -32,6 +32,7 @@ class TestSemanticSearchFunctionSignature:
"structural_weight",
"keyword_weight",
"fusion_strategy",
"staged_stage2_mode",
"kind_filter",
"limit",
"include_match_reason",
@@ -49,6 +50,7 @@ class TestSemanticSearchFunctionSignature:
assert sig.parameters["structural_weight"].default == 0.3
assert sig.parameters["keyword_weight"].default == 0.2
assert sig.parameters["fusion_strategy"].default == "rrf"
assert sig.parameters["staged_stage2_mode"].default is None
assert sig.parameters["kind_filter"].default is None
assert sig.parameters["limit"].default == 20
assert sig.parameters["include_match_reason"].default is False