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:
catlog22
2026-02-17 20:02:44 +08:00
parent 41c6f07ee0
commit c67bf86244
27 changed files with 696 additions and 241 deletions

View File

@@ -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 */}

View File

@@ -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}
/>

View File

@@ -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';