// ======================================== // CLI Viewer Page // ======================================== // Multi-pane CLI output viewer with configurable layouts // Integrates with viewerStore for state management // Includes WebSocket integration and execution recovery import { useEffect, useCallback, useMemo, useRef } 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 { useViewerStore, useViewerLayout, useViewerPanes, useFocusedPaneId, type AllotmentLayout, } from '@/stores/viewerStore'; import { useCliStreamStore, type CliOutputLine } from '@/stores/cliStreamStore'; import { useNotificationStore, selectWsLastMessage } from '@/stores'; import { useActiveCliExecutions, useInvalidateActiveCliExecutions } from '@/hooks/useActiveCliExecutions'; // ======================================== // 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; tool: string; mode: string; timestamp: string; } interface CliStreamOutputPayload { executionId: string; chunkType: string; data: unknown; unit?: { content: unknown; type?: string; }; } interface CliStreamCompletedPayload { executionId: string; success: boolean; duration?: number; timestamp: string; } interface CliStreamErrorPayload { executionId: string; error?: string; timestamp: string; } // ======================================== // 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'; // ======================================== // Helper Functions // ======================================== function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; const seconds = Math.floor(ms / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; if (minutes < 60) return `${minutes}m ${remainingSeconds}s`; const hours = Math.floor(minutes / 60); const remainingMinutes = minutes % 60; 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 */ function countPanes(layout: AllotmentLayout): number { let count = 0; const traverse = (children: (string | AllotmentLayout)[]) => { for (const child of children) { if (typeof child === 'string') { count++; } else { traverse(child.children); } } }; traverse(layout.children); return count; } // ======================================== // Main Component // ======================================== export function CliViewerPage() { const { formatMessage } = useIntl(); const [searchParams, setSearchParams] = useSearchParams(); // Store hooks const layout = useViewerLayout(); const panes = useViewerPanes(); const focusedPaneId = useFocusedPaneId(); const { initializeDefaultLayout, addTab, reset } = useViewerStore(); // CLI Stream Store hooks const executions = useCliStreamStore((state) => state.executions); // Track last processed WebSocket message to prevent duplicate processing const lastProcessedMsgRef = useRef(null); // WebSocket last message from notification store const lastMessage = useNotificationStore(selectWsLastMessage); // Active execution sync from server const { isLoading: isSyncing } = useActiveCliExecutions(true); // Always sync when page is open 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; lastProcessedMsgRef.current = lastMessage; const { type, payload } = lastMessage; if (type === 'CLI_STARTED') { const p = payload as CliStreamStartedPayload; const startTime = p.timestamp ? new Date(p.timestamp).getTime() : Date.now(); useCliStreamStore.getState().upsertExecution(p.executionId, { tool: p.tool || 'cli', mode: p.mode || 'analysis', status: 'running', startTime, output: [ { type: 'system', content: `[${new Date(startTime).toLocaleTimeString()}] CLI execution started: ${p.tool} (${p.mode} mode)`, timestamp: startTime } ] }); invalidateActive(); } else if (type === 'CLI_OUTPUT') { const p = payload as CliStreamOutputPayload; const unitContent = p.unit?.content ?? p.data; const unitType = p.unit?.type || p.chunkType; let content: string; if (unitType === 'tool_call' && typeof unitContent === 'object' && unitContent !== null) { const toolCall = unitContent as { action?: string; toolName?: string; parameters?: unknown; status?: string; output?: string }; if (toolCall.action === 'invoke') { const params = toolCall.parameters ? JSON.stringify(toolCall.parameters) : ''; content = `[Tool] ${toolCall.toolName}(${params})`; } else if (toolCall.action === 'result') { const status = toolCall.status || 'unknown'; const output = toolCall.output ? `: ${toolCall.output.substring(0, 200)}${toolCall.output.length > 200 ? '...' : ''}` : ''; content = `[Tool Result] ${status}${output}`; } else { content = JSON.stringify(unitContent); } } else { content = typeof unitContent === 'string' ? unitContent : JSON.stringify(unitContent); } const lines = content.split('\n'); const addOutput = useCliStreamStore.getState().addOutput; lines.forEach(line => { if (line.trim() || lines.length === 1) { addOutput(p.executionId, { type: (unitType as CliOutputLine['type']) || 'stdout', content: line, timestamp: Date.now() }); } }); } else if (type === 'CLI_COMPLETED') { const p = payload as CliStreamCompletedPayload; const endTime = p.timestamp ? new Date(p.timestamp).getTime() : Date.now(); useCliStreamStore.getState().upsertExecution(p.executionId, { status: p.success ? 'completed' : 'error', endTime, output: [ { type: 'system', content: `[${new Date(endTime).toLocaleTimeString()}] CLI execution ${p.success ? 'completed successfully' : 'failed'}${p.duration ? ` (${formatDuration(p.duration)})` : ''}`, timestamp: endTime } ] }); invalidateActive(); } else if (type === 'CLI_ERROR') { const p = payload as CliStreamErrorPayload; const endTime = p.timestamp ? new Date(p.timestamp).getTime() : Date.now(); useCliStreamStore.getState().upsertExecution(p.executionId, { status: 'error', endTime, output: [ { type: 'stderr', content: `[ERROR] ${p.error || 'Unknown error occurred'}`, timestamp: endTime } ] }); invalidateActive(); } }, [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>(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})`); }); }, [executions, panes]); // Initialize layout if empty useEffect(() => { const paneCount = countPanes(layout); if (paneCount === 0) { initializeDefaultLayout(DEFAULT_LAYOUT); } }, [layout, initializeDefaultLayout]); // Handle executionId from URL params 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'); return newParams; }); } }, [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; return (
{/* ======================================== */} {/* Toolbar */} {/* ======================================== */}
{/* Page Title */}
{formatMessage({ id: 'cliViewer.page.title' })} {runningCount > 0 && ( {runningCount} active )}
{formatMessage( { id: 'cliViewer.page.subtitle' }, { count: activeSessionCount } )} {executionCount > 0 && ` ยท ${executionCount} executions`}
{/* Actions */}
{/* Reset Button */} {/* Layout Selector */} {formatMessage({ id: 'cliViewer.layout.title' })} {LAYOUT_OPTIONS.map((option) => { const Icon = option.icon; return ( handleLayoutChange(option.id)} className={cn( 'gap-2', currentLayoutType === option.id && 'bg-accent' )} > {formatMessage({ id: option.labelKey })} ); })}
{/* ======================================== */} {/* Layout Container */} {/* ======================================== */}
); } export default CliViewerPage;