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

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

View File

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

View File

@@ -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', () => {

View File

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

View File

@@ -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"

View File

@@ -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"

View File

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

View File

@@ -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,

View File

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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"

View File

@@ -41,7 +41,12 @@
"saveToConfig": "保存到配置文件",
"saving": "保存中...",
"configSaved": "配置已保存到 ~/.claude/cli-tools.json",
"configSaveError": "保存配置失败"
"configSaveError": "保存配置失败",
"effort": "思考力度",
"effortHint": "控制 Claude 会话的思考力度。默认high。",
"effortLow": "低",
"effortMedium": "中",
"effortHigh": "高"
},
"display": {
"title": "显示设置",

View File

@@ -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": "暂无终端会话"

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

View File

@@ -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 {

View File

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

View File

@@ -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)

View File

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

View File

@@ -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 {

View File

@@ -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(() => {

View File

@@ -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);
}

View File

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

View File

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

View File

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