mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
feat(cli-tools): add effort level configuration for Claude CLI
- Introduced effort level options (low, medium, high) in the CLI tool settings. - Updated the SettingsPage and CliToolCard components to handle effort level updates. - Enhanced CLI command options to accept effort level via --effort parameter. - Modified backend routes to support effort level updates in tool configurations. - Created a new CliViewerToolbar component for improved CLI viewer interactions. - Implemented logic to manage and display execution statuses and layouts in the CLI viewer.
This commit is contained in:
472
ccw/frontend/src/components/cli-viewer/CliViewerToolbar.tsx
Normal file
472
ccw/frontend/src/components/cli-viewer/CliViewerToolbar.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
// ========================================
|
||||
// CliViewerToolbar Component
|
||||
// ========================================
|
||||
// Compact icon-based toolbar for CLI Viewer page
|
||||
// Follows DashboardToolbar design pattern
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Square,
|
||||
Columns2,
|
||||
Rows2,
|
||||
LayoutGrid,
|
||||
Plus,
|
||||
ChevronDown,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
RotateCcw,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/Dropdown';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/Dialog';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Search, Clock, CheckCircle2, XCircle, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
useViewerStore,
|
||||
useViewerLayout,
|
||||
useFocusedPaneId,
|
||||
type AllotmentLayout,
|
||||
} from '@/stores/viewerStore';
|
||||
import { useCliStreamStore, type CliExecutionStatus } from '@/stores/cliStreamStore';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface CliViewerToolbarProps {
|
||||
/** Whether fullscreen mode is active */
|
||||
isFullscreen?: boolean;
|
||||
/** Callback to toggle fullscreen mode */
|
||||
onToggleFullscreen?: () => void;
|
||||
}
|
||||
|
||||
export type LayoutType = 'single' | 'split-h' | 'split-v' | 'grid-2x2';
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
const LAYOUT_PRESETS = [
|
||||
{ id: 'single' as const, icon: Square, labelId: 'cliViewer.layout.single' },
|
||||
{ id: 'split-h' as const, icon: Columns2, labelId: 'cliViewer.layout.splitH' },
|
||||
{ id: 'split-v' as const, icon: Rows2, labelId: 'cliViewer.layout.splitV' },
|
||||
{ id: 'grid-2x2' as const, icon: LayoutGrid, labelId: 'cliViewer.layout.grid' },
|
||||
];
|
||||
|
||||
const DEFAULT_LAYOUT: LayoutType = 'split-h';
|
||||
|
||||
const STATUS_CONFIG: Record<CliExecutionStatus, { color: string }> = {
|
||||
running: { color: 'bg-blue-500 animate-pulse' },
|
||||
completed: { color: 'bg-green-500' },
|
||||
error: { color: 'bg-red-500' },
|
||||
};
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
/**
|
||||
* Detect layout type from AllotmentLayout structure
|
||||
*/
|
||||
function detectLayoutType(layout: AllotmentLayout): LayoutType {
|
||||
const childCount = layout.children.length;
|
||||
|
||||
if (childCount === 0 || childCount === 1) {
|
||||
return 'single';
|
||||
}
|
||||
|
||||
if (childCount === 2) {
|
||||
const hasNestedGroups = layout.children.some(
|
||||
(child) => typeof child !== 'string'
|
||||
);
|
||||
|
||||
if (!hasNestedGroups) {
|
||||
return layout.direction === 'horizontal' ? 'split-h' : 'split-v';
|
||||
}
|
||||
|
||||
const allNested = layout.children.every(
|
||||
(child) => typeof child !== 'string'
|
||||
);
|
||||
if (allNested) {
|
||||
return 'grid-2x2';
|
||||
}
|
||||
}
|
||||
|
||||
return layout.direction === 'horizontal' ? 'split-h' : 'split-v';
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp;
|
||||
|
||||
if (diff < 60000) return 'Just now';
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||
return new Date(timestamp).toLocaleDateString();
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function CliViewerToolbar({
|
||||
isFullscreen,
|
||||
onToggleFullscreen,
|
||||
}: CliViewerToolbarProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Store hooks
|
||||
const layout = useViewerLayout();
|
||||
const focusedPaneId = useFocusedPaneId();
|
||||
const { initializeDefaultLayout, reset, addTab } = useViewerStore();
|
||||
|
||||
// CLI Stream Store
|
||||
const executions = useCliStreamStore((state) => state.executions);
|
||||
|
||||
// Detect current layout type
|
||||
const currentLayoutType = useMemo(() => detectLayoutType(layout), [layout]);
|
||||
|
||||
// Get execution count for display
|
||||
const executionCount = useMemo(() => Object.keys(executions).length, [executions]);
|
||||
const runningCount = useMemo(
|
||||
() => Object.values(executions).filter((e) => e.status === 'running').length,
|
||||
[executions]
|
||||
);
|
||||
|
||||
// Handle back navigation
|
||||
const handleBack = useCallback(() => {
|
||||
navigate(-1);
|
||||
}, [navigate]);
|
||||
|
||||
// Handle layout change
|
||||
const handleLayoutChange = useCallback(
|
||||
(layoutType: LayoutType) => {
|
||||
initializeDefaultLayout(layoutType);
|
||||
},
|
||||
[initializeDefaultLayout]
|
||||
);
|
||||
|
||||
// Handle reset
|
||||
const handleReset = useCallback(() => {
|
||||
reset();
|
||||
initializeDefaultLayout(DEFAULT_LAYOUT);
|
||||
}, [reset, initializeDefaultLayout]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-2 h-[40px] border-b border-border bg-muted/30 shrink-0">
|
||||
{/* Back button */}
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className={cn(
|
||||
'p-1.5 rounded transition-colors',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
)}
|
||||
title={formatMessage({ id: 'cliViewer.toolbar.back', defaultMessage: 'Back' })}
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
|
||||
{/* Layout presets */}
|
||||
{LAYOUT_PRESETS.map((preset) => {
|
||||
const isActive = currentLayoutType === preset.id;
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
onClick={() => handleLayoutChange(preset.id)}
|
||||
className={cn(
|
||||
'p-1.5 rounded transition-colors',
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
)}
|
||||
title={formatMessage({ id: preset.labelId })}
|
||||
>
|
||||
<preset.icon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
|
||||
{/* Add execution button - Inline Picker */}
|
||||
<AddExecutionButton focusedPaneId={focusedPaneId} />
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
|
||||
{/* Reset button */}
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className={cn(
|
||||
'p-1.5 rounded transition-colors',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
)}
|
||||
title={formatMessage({ id: 'cliViewer.toolbar.clearAll' })}
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{/* Right side - Execution selector & fullscreen */}
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
{/* Execution dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs transition-colors',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
<Terminal className="w-3.5 h-3.5" />
|
||||
<span>
|
||||
{runningCount > 0
|
||||
? `${runningCount} ${formatMessage({ id: 'cliViewer.toolbar.running', defaultMessage: 'running' })}`
|
||||
: `${executionCount} ${formatMessage({ id: 'cliViewer.toolbar.executions', defaultMessage: 'executions' })}`}
|
||||
</span>
|
||||
{runningCount > 0 && (
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
)}
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={4}>
|
||||
<DropdownMenuLabel>
|
||||
{formatMessage({ id: 'cliViewer.toolbar.executionsList', defaultMessage: 'Recent Executions' })}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{Object.entries(executions).length === 0 ? (
|
||||
<div className="px-2 py-4 text-center text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'cliViewer.picker.noExecutions', defaultMessage: 'No executions available' })}
|
||||
</div>
|
||||
) : (
|
||||
Object.entries(executions)
|
||||
.sort((a, b) => b[1].startTime - a[1].startTime)
|
||||
.slice(0, 10)
|
||||
.map(([id, exec]) => (
|
||||
<DropdownMenuItem
|
||||
key={id}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
onClick={() => {
|
||||
if (focusedPaneId) {
|
||||
const title = `${exec.tool}-${exec.mode}`;
|
||||
addTab(focusedPaneId, id, title);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full shrink-0',
|
||||
STATUS_CONFIG[exec.status].color
|
||||
)}
|
||||
/>
|
||||
<span className="truncate flex-1">{exec.tool}</span>
|
||||
<span className="text-muted-foreground">{exec.mode}</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* 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: 'cliViewer.toolbar.exitFullscreen', defaultMessage: 'Exit Fullscreen' })
|
||||
: formatMessage({ id: 'cliViewer.toolbar.fullscreen', defaultMessage: 'Fullscreen' })
|
||||
}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize2 className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Page title */}
|
||||
<span className="text-xs text-muted-foreground font-medium ml-2">
|
||||
{formatMessage({ id: 'cliViewer.page.title' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Add Execution Button Sub-Component ==========
|
||||
|
||||
function AddExecutionButton({ focusedPaneId }: { focusedPaneId: string | null }) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const executions = useCliStreamStore((state) => state.executions);
|
||||
const panes = useViewerStore((state) => state.panes);
|
||||
const addTab = useViewerStore((state) => state.addTab);
|
||||
|
||||
// Get existing execution IDs in current pane
|
||||
const existingExecutionIds = useMemo(() => {
|
||||
if (!focusedPaneId) return new Set<string>();
|
||||
const pane = panes[focusedPaneId];
|
||||
if (!pane) return new Set<string>();
|
||||
return new Set(pane.tabs.map((tab) => tab.executionId));
|
||||
}, [panes, focusedPaneId]);
|
||||
|
||||
// Filter executions
|
||||
const filteredExecutions = useMemo(() => {
|
||||
const entries = Object.entries(executions);
|
||||
const filtered = entries.filter(([id, exec]) => {
|
||||
if (!searchQuery) return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
id.toLowerCase().includes(query) ||
|
||||
exec.tool.toLowerCase().includes(query) ||
|
||||
exec.mode.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
filtered.sort((a, b) => b[1].startTime - a[1].startTime);
|
||||
return filtered;
|
||||
}, [executions, searchQuery]);
|
||||
|
||||
const handleSelect = useCallback((executionId: string, tool: string, mode: string) => {
|
||||
if (focusedPaneId) {
|
||||
addTab(focusedPaneId, executionId, `${tool}-${mode}`);
|
||||
setOpen(false);
|
||||
setSearchQuery('');
|
||||
}
|
||||
}, [focusedPaneId, addTab]);
|
||||
|
||||
if (!focusedPaneId) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs transition-colors',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>{formatMessage({ id: 'cliViewer.toolbar.addExecution', defaultMessage: 'Add' })}</span>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{formatMessage({ id: 'cliViewer.picker.selectExecution', defaultMessage: 'Select Execution' })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={formatMessage({
|
||||
id: 'cliViewer.picker.searchExecutions',
|
||||
defaultMessage: 'Search executions...'
|
||||
})}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Execution list */}
|
||||
<div className="max-h-[300px] overflow-y-auto space-y-2">
|
||||
{filteredExecutions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Terminal className="h-8 w-8 text-muted-foreground mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{Object.keys(executions).length === 0
|
||||
? formatMessage({ id: 'cliViewer.picker.noExecutions', defaultMessage: 'No executions available' })
|
||||
: formatMessage({ id: 'cliViewer.picker.noMatchingExecutions', defaultMessage: 'No matching executions' })}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredExecutions.map(([id, exec]) => {
|
||||
const isAlreadyOpen = existingExecutionIds.has(id);
|
||||
return (
|
||||
<div key={id} className="relative">
|
||||
<button
|
||||
onClick={() => handleSelect(id, exec.tool, exec.mode)}
|
||||
disabled={isAlreadyOpen}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 p-3 rounded-lg',
|
||||
'border border-border/50 bg-muted/30',
|
||||
'hover:bg-muted/50 hover:border-border',
|
||||
'transition-all duration-150',
|
||||
'text-left',
|
||||
isAlreadyOpen && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{/* Tool icon */}
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-md bg-primary/10">
|
||||
<Terminal className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
|
||||
{/* Execution info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm text-foreground truncate">
|
||||
{exec.tool}-{exec.mode}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full shrink-0',
|
||||
STATUS_CONFIG[exec.status].color
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{formatTime(exec.startTime)}</span>
|
||||
</div>
|
||||
</button>
|
||||
{isAlreadyOpen && (
|
||||
<div className="absolute inset-0 bg-background/60 rounded-lg flex items-center justify-center">
|
||||
<span className="text-xs text-muted-foreground bg-muted px-2 py-1 rounded">
|
||||
{formatMessage({ id: 'cliViewer.picker.alreadyOpen', defaultMessage: 'Already open' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default CliViewerToolbar;
|
||||
@@ -26,3 +26,7 @@ export type { ContentAreaProps } from './ContentArea';
|
||||
// Empty state
|
||||
export { EmptyState } from './EmptyState';
|
||||
export type { EmptyStateProps } from './EmptyState';
|
||||
|
||||
// Toolbar
|
||||
export { CliViewerToolbar } from './CliViewerToolbar';
|
||||
export type { CliViewerToolbarProps, LayoutType } from './CliViewerToolbar';
|
||||
|
||||
@@ -118,8 +118,8 @@ describe('ExecutionGroup', () => {
|
||||
}
|
||||
|
||||
// After expand, items should be visible
|
||||
const expandedContainer = document.querySelector('.space-y-1.mt-2');
|
||||
// Note: This test verifies the click handler works; state change verification
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
});
|
||||
|
||||
it('should be clickable via header', () => {
|
||||
|
||||
@@ -128,7 +128,7 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
|
||||
// Launch CLI handlers
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
const focusedPaneId = useTerminalGridStore(selectTerminalGridFocusedPaneId);
|
||||
const panes = useTerminalGridStore((s) => s.panes);
|
||||
// panes available via: useTerminalGridStore((s) => s.panes)
|
||||
const createSessionAndAssign = useTerminalGridStore((s) => s.createSessionAndAssign);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [selectedTool, setSelectedTool] = useState<CliTool>('gemini');
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { FolderOpen, RefreshCw, Loader2, ChevronLeft, FileText } from 'lucide-react';
|
||||
import { FolderOpen, RefreshCw, Loader2, ChevronLeft, FileText, Eye, EyeOff } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { TreeView } from '@/components/shared/TreeView';
|
||||
@@ -50,6 +50,7 @@ export function FileSidebarPanel({
|
||||
refetch,
|
||||
setSelectedFile,
|
||||
toggleExpanded,
|
||||
toggleShowHidden,
|
||||
} = useFileExplorer({
|
||||
rootPath,
|
||||
maxDepth: 6,
|
||||
@@ -139,6 +140,15 @@ export function FileSidebarPanel({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={toggleShowHidden}
|
||||
title={formatMessage({ id: 'terminalDashboard.fileBrowser.showHidden' })}
|
||||
>
|
||||
{state.showHiddenFiles ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Copy, ArrowRightToLine, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { Copy, ArrowRightToLine, Loader2, RefreshCw, Eye, EyeOff } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FloatingPanel } from './FloatingPanel';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -42,6 +42,7 @@ export function FloatingFileBrowser({
|
||||
refetch,
|
||||
setSelectedFile,
|
||||
toggleExpanded,
|
||||
toggleShowHidden,
|
||||
} = useFileExplorer({
|
||||
rootPath,
|
||||
maxDepth: 6,
|
||||
@@ -107,6 +108,17 @@ export function FloatingFileBrowser({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={toggleShowHidden}
|
||||
title={formatMessage({ id: 'terminalDashboard.fileBrowser.showHidden' })}
|
||||
>
|
||||
{state.showHiddenFiles ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
} from '@/stores/issueQueueIntegrationStore';
|
||||
import { useCliSessionStore } from '@/stores/cliSessionStore';
|
||||
import { getAllPaneIds } from '@/lib/layout-utils';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import { useFileContent } from '@/hooks/useFileExplorer';
|
||||
import type { PaneId } from '@/stores/viewerStore';
|
||||
import type { TerminalStatus } from '@/types/terminal-dashboard';
|
||||
@@ -86,8 +85,6 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
|
||||
const canClose = getAllPaneIds(layout).length > 1;
|
||||
const isFileMode = displayMode === 'file' && filePath;
|
||||
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
// Session data
|
||||
const groups = useSessionManagerStore(selectGroups);
|
||||
const terminalMetas = useSessionManagerStore(selectTerminalMetas);
|
||||
|
||||
@@ -109,7 +109,7 @@ export function useFileExplorer(options: UseFileExplorerOptions = {}): UseFileEx
|
||||
const {
|
||||
rootPath = '/',
|
||||
maxDepth = 5,
|
||||
includeHidden = false,
|
||||
// includeHidden is now controlled by internal showHiddenFiles state
|
||||
excludePatterns,
|
||||
staleTime,
|
||||
enabled = true,
|
||||
@@ -126,10 +126,10 @@ export function useFileExplorer(options: UseFileExplorerOptions = {}): UseFileEx
|
||||
const [filter, setFilterState] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<SearchFilesResponse | undefined>();
|
||||
|
||||
// Fetch file tree
|
||||
// Fetch file tree - use showHiddenFiles state instead of options.includeHidden
|
||||
const treeQuery = useQuery({
|
||||
queryKey: fileExplorerKeys.tree(rootPath),
|
||||
queryFn: () => fetchFileTree(rootPath, { maxDepth, includeHidden, excludePatterns }),
|
||||
queryKey: [...fileExplorerKeys.tree(rootPath), { showHidden: showHiddenFiles }],
|
||||
queryFn: () => fetchFileTree(rootPath, { maxDepth, includeHidden: showHiddenFiles, excludePatterns }),
|
||||
staleTime: staleTime ?? TREE_STALE_TIME,
|
||||
enabled,
|
||||
retry: 2,
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
addPaneToLayout,
|
||||
getAllPaneIds,
|
||||
} from './layout-utils';
|
||||
import type { AllotmentLayoutGroup, PaneId } from '@/stores/viewerStore';
|
||||
import type { AllotmentLayoutGroup } from '@/stores/viewerStore';
|
||||
|
||||
describe('layout-utils', () => {
|
||||
// Helper to create test layouts
|
||||
|
||||
@@ -21,7 +21,14 @@
|
||||
"toolbar": {
|
||||
"refresh": "Refresh",
|
||||
"clearAll": "Clear All",
|
||||
"settings": "Settings"
|
||||
"settings": "Settings",
|
||||
"back": "Back",
|
||||
"addExecution": "Add",
|
||||
"running": "running",
|
||||
"executions": "executions",
|
||||
"executionsList": "Recent Executions",
|
||||
"fullscreen": "Fullscreen",
|
||||
"exitFullscreen": "Exit Fullscreen"
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "No CLI Executions",
|
||||
|
||||
@@ -41,7 +41,12 @@
|
||||
"saveToConfig": "Save to Config",
|
||||
"saving": "Saving...",
|
||||
"configSaved": "Configuration saved to ~/.claude/cli-tools.json",
|
||||
"configSaveError": "Failed to save configuration"
|
||||
"configSaveError": "Failed to save configuration",
|
||||
"effort": "Effort Level",
|
||||
"effortHint": "Controls thinking effort for Claude sessions. Default: high.",
|
||||
"effortLow": "Low",
|
||||
"effortMedium": "Medium",
|
||||
"effortHigh": "High"
|
||||
},
|
||||
"display": {
|
||||
"title": "Display Settings",
|
||||
|
||||
@@ -84,7 +84,8 @@
|
||||
"modeDefault": "Default",
|
||||
"modeYolo": "Yolo",
|
||||
"quickCreate": "Quick Create",
|
||||
"configure": "Configure..."
|
||||
"configure": "Configure...",
|
||||
"fullscreen": "Fullscreen"
|
||||
},
|
||||
"cliConfig": {
|
||||
"title": "Create CLI Session",
|
||||
@@ -113,7 +114,15 @@
|
||||
"copied": "Copied",
|
||||
"insertPath": "Insert into terminal",
|
||||
"loading": "Loading...",
|
||||
"loadFailed": "Failed to load file tree"
|
||||
"loadFailed": "Failed to load file tree",
|
||||
"showHidden": "Toggle filtered files"
|
||||
},
|
||||
"fileSidebar": {
|
||||
"title": "Files",
|
||||
"refresh": "Refresh",
|
||||
"collapse": "Collapse",
|
||||
"noProject": "No project open",
|
||||
"openProjectHint": "Open a project to browse files"
|
||||
},
|
||||
"artifacts": {
|
||||
"types": {
|
||||
@@ -134,7 +143,8 @@
|
||||
"linkedIssue": "Linked Issue",
|
||||
"restart": "Restart Session",
|
||||
"pause": "Pause Session",
|
||||
"resume": "Resume Session"
|
||||
"resume": "Resume Session",
|
||||
"backToTerminal": "Back to terminal"
|
||||
},
|
||||
"tabBar": {
|
||||
"noTabs": "No terminal sessions"
|
||||
|
||||
@@ -41,7 +41,12 @@
|
||||
"saveToConfig": "保存到配置文件",
|
||||
"saving": "保存中...",
|
||||
"configSaved": "配置已保存到 ~/.claude/cli-tools.json",
|
||||
"configSaveError": "保存配置失败"
|
||||
"configSaveError": "保存配置失败",
|
||||
"effort": "思考力度",
|
||||
"effortHint": "控制 Claude 会话的思考力度。默认:high。",
|
||||
"effortLow": "低",
|
||||
"effortMedium": "中",
|
||||
"effortHigh": "高"
|
||||
},
|
||||
"display": {
|
||||
"title": "显示设置",
|
||||
|
||||
@@ -84,7 +84,8 @@
|
||||
"modeDefault": "默认",
|
||||
"modeYolo": "Yolo",
|
||||
"quickCreate": "快速创建",
|
||||
"configure": "配置..."
|
||||
"configure": "配置...",
|
||||
"fullscreen": "全屏"
|
||||
},
|
||||
"cliConfig": {
|
||||
"title": "创建 CLI 会话",
|
||||
@@ -113,7 +114,15 @@
|
||||
"copied": "已复制",
|
||||
"insertPath": "插入到终端",
|
||||
"loading": "加载中...",
|
||||
"loadFailed": "加载文件树失败"
|
||||
"loadFailed": "加载文件树失败",
|
||||
"showHidden": "显示/隐藏过滤文件"
|
||||
},
|
||||
"fileSidebar": {
|
||||
"title": "文件",
|
||||
"refresh": "刷新",
|
||||
"collapse": "折叠",
|
||||
"noProject": "未打开项目",
|
||||
"openProjectHint": "打开项目以浏览文件"
|
||||
},
|
||||
"artifacts": {
|
||||
"types": {
|
||||
@@ -134,7 +143,8 @@
|
||||
"linkedIssue": "关联问题",
|
||||
"restart": "重启会话",
|
||||
"pause": "暂停会话",
|
||||
"resume": "恢复会话"
|
||||
"resume": "恢复会话",
|
||||
"backToTerminal": "返回终端"
|
||||
},
|
||||
"tabBar": {
|
||||
"noTabs": "暂无终端会话"
|
||||
|
||||
@@ -5,29 +5,9 @@
|
||||
// Integrates with viewerStore for state management
|
||||
// Includes WebSocket integration and execution recovery
|
||||
|
||||
import { useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Terminal,
|
||||
LayoutGrid,
|
||||
Columns,
|
||||
Rows,
|
||||
Square,
|
||||
ChevronDown,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/Dropdown';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LayoutContainer } from '@/components/cli-viewer';
|
||||
import { LayoutContainer, CliViewerToolbar } from '@/components/cli-viewer';
|
||||
import {
|
||||
useViewerStore,
|
||||
useViewerLayout,
|
||||
@@ -43,14 +23,6 @@ import { useActiveCliExecutions, useInvalidateActiveCliExecutions } from '@/hook
|
||||
// Types
|
||||
// ========================================
|
||||
|
||||
export type LayoutType = 'single' | 'split-h' | 'split-v' | 'grid-2x2';
|
||||
|
||||
interface LayoutOption {
|
||||
id: LayoutType;
|
||||
icon: React.ElementType;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
// CLI WebSocket message types (matching CliStreamMonitorLegacy)
|
||||
interface CliStreamStartedPayload {
|
||||
executionId: string;
|
||||
@@ -86,14 +58,7 @@ interface CliStreamErrorPayload {
|
||||
// Constants
|
||||
// ========================================
|
||||
|
||||
const LAYOUT_OPTIONS: LayoutOption[] = [
|
||||
{ id: 'single', icon: Square, labelKey: 'cliViewer.layout.single' },
|
||||
{ id: 'split-h', icon: Columns, labelKey: 'cliViewer.layout.splitH' },
|
||||
{ id: 'split-v', icon: Rows, labelKey: 'cliViewer.layout.splitV' },
|
||||
{ id: 'grid-2x2', icon: LayoutGrid, labelKey: 'cliViewer.layout.grid' },
|
||||
];
|
||||
|
||||
const DEFAULT_LAYOUT: LayoutType = 'split-h';
|
||||
const DEFAULT_LAYOUT = 'split-h' as const;
|
||||
|
||||
// ========================================
|
||||
// Helper Functions
|
||||
@@ -111,41 +76,6 @@ function formatDuration(ms: number): string {
|
||||
return `${hours}h ${remainingMinutes}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect layout type from AllotmentLayout structure
|
||||
*/
|
||||
function detectLayoutType(layout: AllotmentLayout): LayoutType {
|
||||
const childCount = layout.children.length;
|
||||
|
||||
// Empty or single pane
|
||||
if (childCount === 0 || childCount === 1) {
|
||||
return 'single';
|
||||
}
|
||||
|
||||
// Two panes at root level
|
||||
if (childCount === 2) {
|
||||
const hasNestedGroups = layout.children.some(
|
||||
(child) => typeof child !== 'string'
|
||||
);
|
||||
|
||||
// If no nested groups, it's a simple split
|
||||
if (!hasNestedGroups) {
|
||||
return layout.direction === 'horizontal' ? 'split-h' : 'split-v';
|
||||
}
|
||||
|
||||
// Check for grid layout (2x2)
|
||||
const allNested = layout.children.every(
|
||||
(child) => typeof child !== 'string'
|
||||
);
|
||||
if (allNested) {
|
||||
return 'grid-2x2';
|
||||
}
|
||||
}
|
||||
|
||||
// Default to current direction
|
||||
return layout.direction === 'horizontal' ? 'split-h' : 'split-v';
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total panes in layout
|
||||
*/
|
||||
@@ -169,14 +99,16 @@ function countPanes(layout: AllotmentLayout): number {
|
||||
// ========================================
|
||||
|
||||
export function CliViewerPage() {
|
||||
const { formatMessage } = useIntl();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Fullscreen state
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
// Store hooks
|
||||
const layout = useViewerLayout();
|
||||
const panes = useViewerPanes();
|
||||
const focusedPaneId = useFocusedPaneId();
|
||||
const { initializeDefaultLayout, addTab, reset } = useViewerStore();
|
||||
const { initializeDefaultLayout, addTab } = useViewerStore();
|
||||
|
||||
// CLI Stream Store hooks
|
||||
const executions = useCliStreamStore((state) => state.executions);
|
||||
@@ -188,24 +120,9 @@ export function CliViewerPage() {
|
||||
const lastMessage = useNotificationStore(selectWsLastMessage);
|
||||
|
||||
// Active execution sync from server
|
||||
const { isLoading: _isSyncing } = useActiveCliExecutions(true); // Always sync when page is open
|
||||
useActiveCliExecutions(true);
|
||||
const invalidateActive = useInvalidateActiveCliExecutions();
|
||||
|
||||
// Detect current layout type from store
|
||||
const currentLayoutType = useMemo(() => detectLayoutType(layout), [layout]);
|
||||
|
||||
// Count active sessions (tabs across all panes)
|
||||
const activeSessionCount = useMemo(() => {
|
||||
return Object.values(panes).reduce((count, pane) => count + pane.tabs.length, 0);
|
||||
}, [panes]);
|
||||
|
||||
// Get execution count for display
|
||||
const executionCount = useMemo(() => Object.keys(executions).length, [executions]);
|
||||
const runningCount = useMemo(
|
||||
() => Object.values(executions).filter(e => e.status === 'running').length,
|
||||
[executions]
|
||||
);
|
||||
|
||||
// Handle WebSocket messages for CLI stream (same logic as CliStreamMonitorLegacy)
|
||||
useEffect(() => {
|
||||
if (!lastMessage || lastMessage === lastProcessedMsgRef.current) return;
|
||||
@@ -297,30 +214,22 @@ export function CliViewerPage() {
|
||||
}, [lastMessage, invalidateActive]);
|
||||
|
||||
// Auto-add new executions as tabs, distributing across available panes
|
||||
// Uses round-robin distribution to spread executions across panes side-by-side
|
||||
const addedExecutionsRef = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
// Get all pane IDs from the current layout
|
||||
const paneIds = Object.keys(panes);
|
||||
if (paneIds.length === 0) return;
|
||||
|
||||
// Get addTab from store directly to avoid dependency on reactive function
|
||||
// This prevents infinite loop when addTab updates store state
|
||||
const storeAddTab = useViewerStore.getState().addTab;
|
||||
|
||||
// Get new executions that haven't been added yet
|
||||
const newExecutionIds = Object.keys(executions).filter(
|
||||
(id) => !addedExecutionsRef.current.has(id)
|
||||
);
|
||||
|
||||
if (newExecutionIds.length === 0) return;
|
||||
|
||||
// Distribute new executions across panes round-robin
|
||||
newExecutionIds.forEach((executionId, index) => {
|
||||
addedExecutionsRef.current.add(executionId);
|
||||
const exec = executions[executionId];
|
||||
const toolShort = exec.tool.split('-')[0];
|
||||
// Round-robin pane selection
|
||||
const targetPaneId = paneIds[index % paneIds.length];
|
||||
storeAddTab(targetPaneId, executionId, `${toolShort} (${exec.mode})`);
|
||||
});
|
||||
@@ -338,10 +247,7 @@ export function CliViewerPage() {
|
||||
useEffect(() => {
|
||||
const executionId = searchParams.get('executionId');
|
||||
if (executionId && focusedPaneId) {
|
||||
// Add tab to focused pane
|
||||
addTab(focusedPaneId, executionId, `Execution ${executionId.slice(0, 8)}`);
|
||||
|
||||
// Clear the URL param after processing
|
||||
setSearchParams((prev) => {
|
||||
const newParams = new URLSearchParams(prev);
|
||||
newParams.delete('executionId');
|
||||
@@ -350,104 +256,20 @@ export function CliViewerPage() {
|
||||
}
|
||||
}, [searchParams, focusedPaneId, addTab, setSearchParams]);
|
||||
|
||||
// Handle layout change
|
||||
const handleLayoutChange = useCallback(
|
||||
(layoutType: LayoutType) => {
|
||||
initializeDefaultLayout(layoutType);
|
||||
},
|
||||
[initializeDefaultLayout]
|
||||
);
|
||||
|
||||
// Handle reset
|
||||
const handleReset = useCallback(() => {
|
||||
reset();
|
||||
initializeDefaultLayout(DEFAULT_LAYOUT);
|
||||
}, [reset, initializeDefaultLayout]);
|
||||
|
||||
// Get current layout option for display
|
||||
const currentLayoutOption =
|
||||
LAYOUT_OPTIONS.find((l) => l.id === currentLayoutType) || LAYOUT_OPTIONS[1];
|
||||
const CurrentLayoutIcon = currentLayoutOption.icon;
|
||||
// Toggle fullscreen handler
|
||||
const handleToggleFullscreen = () => {
|
||||
setIsFullscreen((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* ======================================== */}
|
||||
{/* Toolbar */}
|
||||
{/* ======================================== */}
|
||||
<div className="flex items-center justify-between gap-3 p-3 bg-card border-b border-border">
|
||||
{/* Page Title */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Terminal className="w-5 h-5 text-primary flex-shrink-0" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'cliViewer.page.title' })}
|
||||
</span>
|
||||
{runningCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-green-500/10 text-green-600 dark:text-green-400 text-xs font-medium">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||
{runningCount} active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatMessage(
|
||||
{ id: 'cliViewer.page.subtitle' },
|
||||
{ count: activeSessionCount }
|
||||
)}
|
||||
{executionCount > 0 && ` · ${executionCount} executions`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Reset Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
title={formatMessage({ id: 'cliViewer.toolbar.clearAll' })}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* Layout Selector */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<CurrentLayoutIcon className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">
|
||||
{formatMessage({ id: currentLayoutOption.labelKey })}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>
|
||||
{formatMessage({ id: 'cliViewer.layout.title' })}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{LAYOUT_OPTIONS.map((option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
onClick={() => handleLayoutChange(option.id)}
|
||||
className={cn(
|
||||
'gap-2',
|
||||
currentLayoutType === option.id && 'bg-accent'
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{formatMessage({ id: option.labelKey })}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<CliViewerToolbar
|
||||
isFullscreen={isFullscreen}
|
||||
onToggleFullscreen={handleToggleFullscreen}
|
||||
/>
|
||||
|
||||
{/* ======================================== */}
|
||||
{/* Layout Container */}
|
||||
|
||||
@@ -127,6 +127,7 @@ interface CliToolCardProps {
|
||||
onUpdateAvailableModels: (models: string[]) => void;
|
||||
onUpdateEnvFile: (envFile: string | undefined) => void;
|
||||
onUpdateSettingsFile: (settingsFile: string | undefined) => void;
|
||||
onUpdateEffort: (effort: string | undefined) => void;
|
||||
onSaveToBackend: () => void;
|
||||
}
|
||||
|
||||
@@ -145,6 +146,7 @@ function CliToolCard({
|
||||
onUpdateAvailableModels,
|
||||
onUpdateEnvFile,
|
||||
onUpdateSettingsFile,
|
||||
onUpdateEffort,
|
||||
onSaveToBackend,
|
||||
}: CliToolCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
@@ -449,6 +451,39 @@ function CliToolCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Effort Level - for claude only */}
|
||||
{configFileType === 'settingsFile' && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'settings.cliTools.effort' })}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(['low', 'medium', 'high'] as const).map((level) => {
|
||||
const effectiveEffort = config.effort || 'high';
|
||||
const labelId = `settings.cliTools.effort${level.charAt(0).toUpperCase() + level.slice(1)}` as const;
|
||||
return (
|
||||
<button
|
||||
key={level}
|
||||
type="button"
|
||||
onClick={() => onUpdateEffort(level === 'high' && !config.effort ? undefined : level)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-md text-sm border transition-colors',
|
||||
effectiveEffort === level
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'border-border hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
{formatMessage({ id: labelId })}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'settings.cliTools.effortHint' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
{!isDefault && config.enabled && (
|
||||
@@ -948,6 +983,7 @@ interface CliToolsWithStatusProps {
|
||||
onUpdateAvailableModels: (toolId: string, models: string[]) => void;
|
||||
onUpdateEnvFile: (toolId: string, envFile: string | undefined) => void;
|
||||
onUpdateSettingsFile: (toolId: string, settingsFile: string | undefined) => void;
|
||||
onUpdateEffort: (toolId: string, effort: string | undefined) => void;
|
||||
onSaveToBackend: (toolId: string) => void;
|
||||
formatMessage: ReturnType<typeof useIntl>['formatMessage'];
|
||||
}
|
||||
@@ -965,6 +1001,7 @@ function CliToolsWithStatus({
|
||||
onUpdateAvailableModels,
|
||||
onUpdateEnvFile,
|
||||
onUpdateSettingsFile,
|
||||
onUpdateEffort,
|
||||
onSaveToBackend,
|
||||
formatMessage,
|
||||
}: CliToolsWithStatusProps) {
|
||||
@@ -995,6 +1032,7 @@ function CliToolsWithStatus({
|
||||
onUpdateAvailableModels={(models) => onUpdateAvailableModels(toolId, models)}
|
||||
onUpdateEnvFile={(envFile) => onUpdateEnvFile(toolId, envFile)}
|
||||
onUpdateSettingsFile={(settingsFile) => onUpdateSettingsFile(toolId, settingsFile)}
|
||||
onUpdateEffort={(effort) => onUpdateEffort(toolId, effort)}
|
||||
onSaveToBackend={() => onSaveToBackend(toolId)}
|
||||
/>
|
||||
);
|
||||
@@ -1057,6 +1095,10 @@ export function SettingsPage() {
|
||||
updateCliTool(toolId, { settingsFile });
|
||||
};
|
||||
|
||||
const handleUpdateEffort = (toolId: string, effort: string | undefined) => {
|
||||
updateCliTool(toolId, { effort });
|
||||
};
|
||||
|
||||
// Save tool config to backend (~/.claude/cli-tools.json)
|
||||
const handleSaveToBackend = useCallback(async (toolId: string) => {
|
||||
const config = cliTools[toolId];
|
||||
@@ -1078,6 +1120,7 @@ export function SettingsPage() {
|
||||
body.envFile = config.envFile || null;
|
||||
} else if (configFileType === 'settingsFile') {
|
||||
body.settingsFile = config.settingsFile || null;
|
||||
body.effort = config.effort || null;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/cli/config/${toolId}`, {
|
||||
@@ -1210,6 +1253,7 @@ export function SettingsPage() {
|
||||
onUpdateAvailableModels={handleUpdateAvailableModels}
|
||||
onUpdateEnvFile={handleUpdateEnvFile}
|
||||
onUpdateSettingsFile={handleUpdateSettingsFile}
|
||||
onUpdateEffort={handleUpdateEffort}
|
||||
onSaveToBackend={handleSaveToBackend}
|
||||
formatMessage={formatMessage}
|
||||
/>
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import { ChevronRight, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { useFlowStore } from '@/stores';
|
||||
import { useAppStore, selectIsImmersiveMode } from '@/stores/appStore';
|
||||
import { useExecutionStore } from '@/stores/executionStore';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { FlowCanvas } from './FlowCanvas';
|
||||
|
||||
@@ -396,6 +396,8 @@ export interface CliToolConfig {
|
||||
/** Path to Claude CLI settings.json, passed via --settings (claude only) */
|
||||
settingsFile?: string;
|
||||
availableModels?: string[];
|
||||
/** Default effort level for claude (low, medium, high) */
|
||||
effort?: string;
|
||||
}
|
||||
|
||||
export interface ApiEndpoints {
|
||||
|
||||
@@ -192,6 +192,8 @@ export function run(argv: string[]): void {
|
||||
.option('--inject-mode <mode>', 'Inject mode: none, full, progressive (default: codex=full, others=none)')
|
||||
// Template/Rules options
|
||||
.option('--rule <template>', 'Template name for auto-discovery (defines $PROTO and $TMPL env vars)')
|
||||
// Claude-specific options
|
||||
.option('--effort <level>', 'Effort level for claude session (low, medium, high)')
|
||||
// Codex review options
|
||||
.option('--uncommitted', 'Review uncommitted changes (codex review)')
|
||||
.option('--base <branch>', 'Review changes against base branch (codex review)')
|
||||
|
||||
@@ -140,6 +140,8 @@ interface CliExecOptions {
|
||||
title?: string; // Optional title for review summary
|
||||
// Template/Rules options
|
||||
rule?: string; // Template name for auto-discovery (defines $PROTO and $TMPL env vars)
|
||||
// Claude-specific options
|
||||
effort?: string; // Effort level for claude: low, medium, high
|
||||
// Output options
|
||||
raw?: boolean; // Raw output only (best for piping)
|
||||
final?: boolean; // Final agent result only (best for piping)
|
||||
@@ -612,6 +614,7 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
|
||||
commit,
|
||||
title,
|
||||
rule,
|
||||
effort,
|
||||
toFile,
|
||||
raw,
|
||||
final: finalOnly,
|
||||
@@ -1044,7 +1047,8 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
|
||||
uncommitted,
|
||||
base,
|
||||
commit,
|
||||
title
|
||||
title,
|
||||
effort
|
||||
// Rules are now concatenated directly into prompt (no env vars)
|
||||
}, onOutput); // Always pass onOutput for real-time dashboard streaming
|
||||
|
||||
@@ -1497,6 +1501,7 @@ export async function cliCommand(
|
||||
console.log(chalk.gray(' --mode <mode> Mode: analysis, write, auto, review (default: analysis)'));
|
||||
console.log(chalk.gray(' -d, --debug Enable debug logging for troubleshooting'));
|
||||
console.log(chalk.gray(' --model <model> Model override (supports PRIMARY_MODEL, SECONDARY_MODEL aliases)'));
|
||||
console.log(chalk.gray(' --effort <level> Effort level for claude (low, medium, high)'));
|
||||
console.log(chalk.gray(' --cd <path> Working directory'));
|
||||
console.log(chalk.gray(' --includeDirs <dirs> Additional directories'));
|
||||
// --timeout removed - controlled by external caller (bash timeout)
|
||||
|
||||
@@ -320,7 +320,7 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
if (req.method === 'PUT') {
|
||||
handlePostRequest(req, res, async (body: unknown) => {
|
||||
try {
|
||||
const updates = body as { enabled?: boolean; primaryModel?: string; secondaryModel?: string; availableModels?: string[]; tags?: string[]; envFile?: string | null; settingsFile?: string | null };
|
||||
const updates = body as { enabled?: boolean; primaryModel?: string; secondaryModel?: string; availableModels?: string[]; tags?: string[]; envFile?: string | null; settingsFile?: string | null; effort?: string | null };
|
||||
const updated = updateToolConfig(initialPath, tool, updates);
|
||||
|
||||
// Broadcast config updated event
|
||||
|
||||
@@ -186,12 +186,9 @@ async function buildFileTree(
|
||||
for (const entry of entries) {
|
||||
const isDirectory = entry.isDirectory();
|
||||
|
||||
// Check if should be ignored
|
||||
if (shouldIgnore(entry.name, gitignorePatterns, isDirectory)) {
|
||||
// Allow hidden files if includeHidden is true and it's .claude or .workflow
|
||||
if (!includeHidden || (!entry.name.startsWith('.claude') && !entry.name.startsWith('.workflow'))) {
|
||||
continue;
|
||||
}
|
||||
// Check if should be ignored (pass includeHidden as showAll to skip all filtering)
|
||||
if (shouldIgnore(entry.name, gitignorePatterns, isDirectory, includeHidden)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryPath = join(normalizedPath, entry.name);
|
||||
@@ -326,17 +323,21 @@ function parseGitignore(gitignorePath: string): string[] {
|
||||
* @param {string} name - File or directory name
|
||||
* @param {string[]} patterns - Gitignore patterns
|
||||
* @param {boolean} isDirectory - Whether the entry is a directory
|
||||
* @param {boolean} showAll - When true, skip hardcoded excludes and hidden file filtering (only apply gitignore)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function shouldIgnore(name: string, patterns: string[], isDirectory: boolean): boolean {
|
||||
// Always exclude certain directories
|
||||
if (isDirectory && EXPLORER_EXCLUDE_DIRS.includes(name)) {
|
||||
return true;
|
||||
}
|
||||
function shouldIgnore(name: string, patterns: string[], isDirectory: boolean, showAll: boolean = false): boolean {
|
||||
// When showAll is true, only apply gitignore patterns (skip hardcoded excludes and hidden files)
|
||||
if (!showAll) {
|
||||
// Always exclude certain directories
|
||||
if (isDirectory && EXPLORER_EXCLUDE_DIRS.includes(name)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Skip hidden files/directories (starting with .)
|
||||
if (name.startsWith('.') && name !== '.claude' && name !== '.workflow') {
|
||||
return true;
|
||||
// Skip hidden files/directories (starting with .)
|
||||
if (name.startsWith('.')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const pattern of patterns) {
|
||||
@@ -626,6 +627,8 @@ export async function handleFilesRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const maxDepth = parseInt(url.searchParams.get('maxDepth') || '6', 10);
|
||||
const includeHidden = url.searchParams.get('includeHidden') === 'true';
|
||||
|
||||
console.log(`[Explorer] Tree request - rootPath: ${rootPath}, includeHidden: ${includeHidden}`);
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { remoteNotificationService } from '../core/services/remote-notification-
|
||||
import {
|
||||
addPendingQuestion,
|
||||
getPendingQuestion,
|
||||
updatePendingQuestion,
|
||||
removePendingQuestion,
|
||||
getAllPendingQuestions,
|
||||
clearAllPendingQuestions,
|
||||
@@ -451,19 +452,30 @@ export async function execute(params: AskQuestionParams): Promise<ToolResult<Ask
|
||||
// Generate surface ID
|
||||
const surfaceId = params.surfaceId || `question-${question.id}-${Date.now()}`;
|
||||
|
||||
// Check if this question was restored from disk (e.g., after MCP restart)
|
||||
const existingPending = getPendingQuestion(question.id);
|
||||
|
||||
// Create promise for answer
|
||||
const resultPromise = new Promise<AskQuestionResult>((resolve, reject) => {
|
||||
// Store pending question
|
||||
// Store pending question with real resolve/reject
|
||||
const pendingQuestion: PendingQuestion = {
|
||||
id: question.id,
|
||||
surfaceId,
|
||||
question,
|
||||
timestamp: Date.now(),
|
||||
timestamp: existingPending?.timestamp || Date.now(),
|
||||
timeout: params.timeout || DEFAULT_TIMEOUT_MS,
|
||||
resolve,
|
||||
reject,
|
||||
};
|
||||
addPendingQuestion(pendingQuestion);
|
||||
|
||||
// If question exists (restored from disk), update it with real resolve/reject
|
||||
// This fixes the "no promise attached" issue when MCP restarts
|
||||
if (existingPending) {
|
||||
updatePendingQuestion(question.id, pendingQuestion);
|
||||
console.log(`[AskQuestion] Updated restored question "${question.id}" with real resolve/reject`);
|
||||
} else {
|
||||
addPendingQuestion(pendingQuestion);
|
||||
}
|
||||
|
||||
// Set timeout
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -62,6 +62,12 @@ export interface ClaudeCliTool {
|
||||
* Supports ~, absolute, relative, and Windows paths
|
||||
*/
|
||||
settingsFile?: string;
|
||||
/**
|
||||
* Default effort level for Claude CLI (builtin claude only)
|
||||
* Passed to Claude CLI via --effort parameter
|
||||
* Valid values: 'low', 'medium', 'high'
|
||||
*/
|
||||
effort?: string;
|
||||
}
|
||||
|
||||
export type CliToolName = 'gemini' | 'qwen' | 'codex' | 'claude' | 'opencode' | string;
|
||||
@@ -1030,6 +1036,7 @@ export function getToolConfig(projectDir: string, tool: string): {
|
||||
tags?: string[];
|
||||
envFile?: string;
|
||||
settingsFile?: string;
|
||||
effort?: string;
|
||||
} {
|
||||
const config = loadClaudeCliTools(projectDir);
|
||||
const toolConfig = config.tools[tool];
|
||||
@@ -1050,7 +1057,8 @@ export function getToolConfig(projectDir: string, tool: string): {
|
||||
secondaryModel: toolConfig.secondaryModel ?? '',
|
||||
tags: toolConfig.tags,
|
||||
envFile: toolConfig.envFile,
|
||||
settingsFile: toolConfig.settingsFile
|
||||
settingsFile: toolConfig.settingsFile,
|
||||
effort: toolConfig.effort
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1068,6 +1076,7 @@ export function updateToolConfig(
|
||||
tags: string[];
|
||||
envFile: string | null;
|
||||
settingsFile: string | null;
|
||||
effort: string | null;
|
||||
}>
|
||||
): ClaudeCliToolsConfig {
|
||||
const config = loadClaudeCliTools(projectDir);
|
||||
@@ -1104,6 +1113,14 @@ export function updateToolConfig(
|
||||
config.tools[tool].settingsFile = updates.settingsFile;
|
||||
}
|
||||
}
|
||||
// Handle effort: set to undefined if null/empty, otherwise set value
|
||||
if (updates.effort !== undefined) {
|
||||
if (updates.effort === null || updates.effort === '') {
|
||||
delete config.tools[tool].effort;
|
||||
} else {
|
||||
config.tools[tool].effort = updates.effort;
|
||||
}
|
||||
}
|
||||
saveClaudeCliTools(projectDir, config);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface CliToolConfig {
|
||||
envFile?: string | null;
|
||||
type?: 'builtin' | 'cli-wrapper' | 'api-endpoint'; // Tool type for frontend routing
|
||||
settingsFile?: string | null; // Claude CLI settings file path
|
||||
effort?: string | null; // Effort level for Claude CLI (low, medium, high)
|
||||
}
|
||||
|
||||
export interface CliConfig {
|
||||
@@ -160,7 +161,8 @@ export function getFullConfigResponse(baseDir: string): {
|
||||
tags: tool.tags,
|
||||
envFile: tool.envFile,
|
||||
type: tool.type, // Preserve type field for frontend routing
|
||||
settingsFile: tool.settingsFile // Preserve settingsFile for Claude CLI
|
||||
settingsFile: tool.settingsFile, // Preserve settingsFile for Claude CLI
|
||||
effort: tool.effort // Preserve effort level for Claude CLI
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -429,6 +429,8 @@ const ParamsSchema = z.object({
|
||||
base: z.string().optional(), // Review changes against base branch
|
||||
commit: z.string().optional(), // Review changes from specific commit
|
||||
title: z.string().optional(), // Optional title for review summary
|
||||
// Claude-specific options
|
||||
effort: z.enum(['low', 'medium', 'high']).optional(), // Effort level for claude
|
||||
// Rules env vars (PROTO, TMPL) - will be passed to subprocess environment
|
||||
rulesEnv: z.object({
|
||||
PROTO: z.string().optional(),
|
||||
@@ -458,7 +460,7 @@ async function executeCliTool(
|
||||
throw new Error(`Invalid params: ${parsed.error.message}`);
|
||||
}
|
||||
|
||||
const { tool, prompt, mode, format, model, cd, includeDirs, resume, id: customId, noNative, category, parentExecutionId, outputFormat, uncommitted, base, commit, title, rulesEnv } = parsed.data;
|
||||
const { tool, prompt, mode, format, model, cd, includeDirs, resume, id: customId, noNative, category, parentExecutionId, outputFormat, uncommitted, base, commit, title, effort, rulesEnv } = parsed.data;
|
||||
|
||||
// Validate and determine working directory early (needed for conversation lookup)
|
||||
let workingDir: string;
|
||||
@@ -881,6 +883,7 @@ async function executeCliTool(
|
||||
|
||||
// Load and validate settings file for Claude tool (builtin only)
|
||||
let settingsFilePath: string | undefined;
|
||||
let effectiveEffort = effort;
|
||||
if (tool === 'claude') {
|
||||
const toolConfig = getToolConfig(workingDir, tool);
|
||||
if (toolConfig.settingsFile) {
|
||||
@@ -896,6 +899,11 @@ async function executeCliTool(
|
||||
errorLog('SETTINGS_FILE', `Failed to resolve Claude settings file`, { configured: toolConfig.settingsFile, error: (err as Error).message });
|
||||
}
|
||||
}
|
||||
// Use default effort from config if not explicitly provided, fallback to 'high'
|
||||
if (!effectiveEffort) {
|
||||
effectiveEffort = toolConfig.effort || 'high';
|
||||
debugLog('EFFORT', `Using effort level`, { effort: effectiveEffort, source: toolConfig.effort ? 'config' : 'default' });
|
||||
}
|
||||
}
|
||||
|
||||
// Build command
|
||||
@@ -908,7 +916,8 @@ async function executeCliTool(
|
||||
include: includeDirs,
|
||||
nativeResume: nativeResumeConfig,
|
||||
settingsFile: settingsFilePath,
|
||||
reviewOptions: mode === 'review' ? { uncommitted, base, commit, title } : undefined
|
||||
reviewOptions: mode === 'review' ? { uncommitted, base, commit, title } : undefined,
|
||||
effort: effectiveEffort
|
||||
});
|
||||
|
||||
// Use auto-detected format (from buildCommand) if available, otherwise use passed outputFormat
|
||||
|
||||
@@ -166,8 +166,10 @@ export function buildCommand(params: {
|
||||
commit?: string;
|
||||
title?: string;
|
||||
};
|
||||
/** Effort level for claude (low, medium, high) */
|
||||
effort?: string;
|
||||
}): { command: string; args: string[]; useStdin: boolean; outputFormat?: 'text' | 'json-lines' } {
|
||||
const { tool, prompt, mode = 'analysis', model, dir, include, nativeResume, settingsFile, reviewOptions } = params;
|
||||
const { tool, prompt, mode = 'analysis', model, dir, include, nativeResume, settingsFile, reviewOptions, effort } = params;
|
||||
|
||||
debugLog('BUILD_CMD', `Building command for tool: ${tool}`, {
|
||||
mode,
|
||||
@@ -331,6 +333,10 @@ export function buildCommand(params: {
|
||||
if (model) {
|
||||
args.push('--model', model);
|
||||
}
|
||||
// Effort level: claude --effort <low|medium|high>
|
||||
if (effort) {
|
||||
args.push('--effort', effort);
|
||||
}
|
||||
// Permission modes: write/auto → bypassPermissions, analysis → default
|
||||
if (mode === 'write' || mode === 'auto') {
|
||||
args.push('--permission-mode', 'bypassPermissions');
|
||||
|
||||
Reference in New Issue
Block a user