// ======================================== // CliStreamPanel Component // ======================================== // Turn-based CLI execution detail view import * as React from 'react'; import { useIntl } from 'react-intl'; import { User, Bot, AlertTriangle, Info, Layers, Clock, Copy, Terminal, Hash, Calendar, CheckCircle2, XCircle, Timer, ChevronDown, ChevronRight, FileJson, Brain, Wrench, Coins, Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/Button'; import { Badge } from '@/components/ui/Badge'; import { Card } from '@/components/ui/Card'; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from '@/components/ui/Dialog'; import { useCliExecutionDetail } from '@/hooks/useCliExecution'; import { useNativeSession } from '@/hooks/useNativeSession'; import type { ConversationRecord, ConversationTurn, NativeSessionTurn, NativeTokenInfo, NativeToolCall } from '@/lib/api'; import { getToolVariant } from '@/lib/cli-tool-theme'; type ViewMode = 'per-turn' | 'concatenated' | 'native'; type ConcatFormat = 'plain' | 'yaml' | 'json'; export interface CliStreamPanelProps { executionId: string; sourceDir?: string; open: boolean; onOpenChange: (open: boolean) => void; } // ========== Types ========== interface TurnSectionProps { turn: ConversationTurn; isLatest: boolean; isExpanded: boolean; onToggle: () => void; } interface ConcatenatedViewProps { prompt: string; format: ConcatFormat; onFormatChange: (fmt: ConcatFormat) => void; } // ========== Helpers ========== /** * Format duration to human readable string */ function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; const seconds = (ms / 1000).toFixed(1); return `${seconds}s`; } /** * Get status icon and color for a turn */ function getStatusInfo(status: string) { const statusMap = { success: { icon: CheckCircle2, color: 'text-green-600 dark:text-green-400' }, error: { icon: XCircle, color: 'text-destructive' }, timeout: { icon: Timer, color: 'text-warning' }, }; return statusMap[status as keyof typeof statusMap] || statusMap.error; } /** * Ensure prompt is a string (handle legacy object data) */ function ensureString(value: unknown): string { if (typeof value === 'string') return value; if (value && typeof value === 'object') return JSON.stringify(value); return String(value ?? ''); } /** * Build concatenated prompt in specified format */ function buildConcatenatedPrompt(execution: ConversationRecord, format: ConcatFormat, formatMessage: (message: { id: string }) => string): string { const turns = execution.turns; if (format === 'plain') { const parts: string[] = []; parts.push(`=== ${formatMessage({ id: 'cli-manager.streamPanel.conversationHistory' })} ===`); parts.push(''); for (const turn of turns) { parts.push(`--- Turn ${turn.turn} ---`); parts.push('USER:'); parts.push(ensureString(turn.prompt)); parts.push(''); parts.push('ASSISTANT:'); parts.push(turn.output.stdout || formatMessage({ id: 'cli-manager.streamPanel.noOutput' })); parts.push(''); } parts.push(`=== ${formatMessage({ id: 'cli-manager.streamPanel.newRequest' })} ===`); parts.push(''); parts.push(formatMessage({ id: 'cli-manager.streamPanel.yourNextPrompt' })); return parts.join('\n'); } if (format === 'yaml') { const yaml: string[] = []; yaml.push('conversation:'); yaml.push(' turns:'); for (const turn of turns) { yaml.push(` - turn: ${turn.turn}`); yaml.push(` timestamp: ${turn.timestamp}`); yaml.push(` prompt: |`); ensureString(turn.prompt).split('\n').forEach(line => { yaml.push(` ${line}`); }); yaml.push(` response: |`); const output = turn.output.stdout || ''; if (output) { output.split('\n').forEach(line => { yaml.push(` ${line}`); }); } else { yaml.push(` ${formatMessage({ id: 'cli-manager.streamPanel.noOutput' })}`); } } return yaml.join('\n'); } // JSON format return JSON.stringify( turns.map((t) => ({ turn: t.turn, timestamp: t.timestamp, prompt: ensureString(t.prompt), response: t.output.stdout || '', })), null, 2 ); } // ========== Sub-Components ========== /** * TurnSection - Single turn display with collapsible content */ function TurnSection({ turn, isLatest, isExpanded, onToggle }: TurnSectionProps) { const { formatMessage } = useIntl(); const StatusIcon = getStatusInfo(turn.status as string).icon; const statusColor = getStatusInfo(turn.status as string).color; return ( {/* Turn Header - Clickable for expand/collapse */}
{ if (e.key === 'Enter' || e.key === ' ') onToggle(); }} >
{isExpanded ? ( ) : ( )} {formatMessage({ id: 'cli.details.turn' })} {turn.turn} {isLatest && ( {formatMessage({ id: 'cli-manager.streamPanel.latest' })} )}
{new Date(turn.timestamp).toLocaleTimeString()} {turn.status} {formatDuration(turn.duration_ms)}
{/* Turn Body - Collapsible */} {isExpanded && (
{/* User Prompt */}

              {ensureString(turn.prompt)}
            
{/* Assistant Response */} {turn.output.stdout && (

                {turn.output.stdout}
              
)} {/* Errors */} {turn.output.stderr && (

                {turn.output.stderr}
              
)} {/* Truncated Notice */} {turn.output.truncated && (
)}
)}
); } /** * PerTurnView - Display all turns with collapsible sections * Default behavior: only latest turn is expanded, others are collapsed */ function PerTurnView({ turns }: { turns: ConversationTurn[] }) { // Default: only the latest turn is expanded const [expandedTurns, setExpandedTurns] = React.useState>(() => { if (turns.length === 0) return new Set(); return new Set([turns[turns.length - 1].turn]); }); const handleToggle = React.useCallback((turnNumber: number) => { setExpandedTurns((prev) => { const next = new Set(prev); if (next.has(turnNumber)) { next.delete(turnNumber); } else { next.add(turnNumber); } return next; }); }, []); return (
{turns.map((turn, idx) => ( handleToggle(turn.turn)} /> {/* Connector line between turns */} {idx < turns.length - 1 && ( ); } /** * ConcatenatedView - Display all turns merged into a single prompt */ function ConcatenatedView({ prompt, format, onFormatChange }: ConcatenatedViewProps) { const { formatMessage } = useIntl(); return (

{(['plain', 'yaml', 'json'] as const).map((fmt) => ( ))}
        {prompt}
      
); } // ========== Native View Components ========== /** * Format token count with compact notation */ function formatTokenCount(count: number | undefined): string { if (count == null || count === 0) return '0'; if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`; return count.toLocaleString(); } /** * NativeTokenDisplay - Compact token info line */ function NativeTokenDisplay({ tokens, className }: { tokens: NativeTokenInfo; className?: string }) { return (
{formatTokenCount(tokens.total)} {tokens.input != null && ( in: {formatTokenCount(tokens.input)} )} {tokens.output != null && ( out: {formatTokenCount(tokens.output)} )}
); } /** * NativeToolCallItem - Single tool call display */ function NativeToolCallItem({ toolCall, index }: { toolCall: NativeToolCall; index: number }) { return (
{toolCall.name} #{index + 1}
{toolCall.arguments && (

Input

              {toolCall.arguments}
            
)} {toolCall.output && (

Output

              {toolCall.output}
            
)}
); } interface NativeTurnCardProps { turn: NativeSessionTurn; isLatest: boolean; isExpanded: boolean; onToggle: () => void; } /** * NativeTurnCard - Single native conversation turn with collapsible content */ function NativeTurnCard({ turn, isLatest, isExpanded, onToggle }: NativeTurnCardProps) { const { formatMessage } = useIntl(); const isUser = turn.role === 'user'; const RoleIcon = isUser ? User : Bot; return ( {/* Header - Clickable */}
{ if (e.key === 'Enter' || e.key === ' ') onToggle(); }} >
{isExpanded ? ( ) : ( )} {turn.role} #{turn.turnNumber} {isLatest && ( {formatMessage({ id: 'cli-manager.streamPanel.latest' })} )}
{turn.timestamp && ( {new Date(turn.timestamp).toLocaleTimeString()} )} {turn.tokens && }
{/* Content - Collapsible */} {isExpanded && (
{turn.content && (
              {ensureString(turn.content)}
            
)} {/* Thoughts Section */} {turn.thoughts && turn.thoughts.length > 0 && (
Thoughts ({turn.thoughts.length})
    {turn.thoughts.map((thought, i) => (
  • {thought}
  • ))}
)} {/* Tool Calls Section */} {turn.toolCalls && turn.toolCalls.length > 0 && (
Tool Calls ({turn.toolCalls.length})
{turn.toolCalls.map((tc, i) => ( ))}
)}
)}
); } interface NativeTurnViewProps { turns: NativeSessionTurn[]; } /** * NativeTurnView - Display native session turns with collapsible cards */ function NativeTurnView({ turns }: NativeTurnViewProps) { // Default: only the latest turn is expanded const [expandedTurns, setExpandedTurns] = React.useState>(() => { if (turns.length === 0) return new Set(); return new Set([turns[turns.length - 1].turnNumber]); }); const handleToggle = React.useCallback((turnNumber: number) => { setExpandedTurns((prev) => { const next = new Set(prev); if (next.has(turnNumber)) { next.delete(turnNumber); } else { next.add(turnNumber); } return next; }); }, []); if (turns.length === 0) { return (
No native session data available
); } return (
{turns.map((turn, idx) => ( handleToggle(turn.turnNumber)} /> {idx < turns.length - 1 && ( ); } // ========== Main Component ========== /** * CliStreamPanel component - Elegant turn-based conversation view * * Displays CLI execution details with: * - Per-turn view with collapsible timeline layout * - Concatenated view for resume context * - Native view for detailed CLI session data * - Format selection (Plain/YAML/JSON) */ export function CliStreamPanel({ executionId, sourceDir: _sourceDir, open, onOpenChange, }: CliStreamPanelProps) { const { formatMessage } = useIntl(); const [viewMode, setViewMode] = React.useState('per-turn'); const [concatFormat, setConcatFormat] = React.useState('plain'); const { data: execution, isLoading } = useCliExecutionDetail(open ? executionId : null); // Load native session only when native view is selected const { data: nativeSession, isLoading: nativeLoading } = useNativeSession( viewMode === 'native' && open ? executionId : null ); // Build concatenated prompt const concatenatedPrompt = React.useMemo(() => { if (!execution?.turns) return ''; return buildConcatenatedPrompt(execution, concatFormat, formatMessage); }, [execution, concatFormat, formatMessage]); // Copy to clipboard const copyToClipboard = React.useCallback( async (text: string, label: string) => { try { await navigator.clipboard.writeText(text); // Optional: add toast notification here console.log(`Copied ${label} to clipboard`); } catch (err) { console.error('Failed to copy:', err); } }, [] ); // Calculate total duration const totalDuration = React.useMemo(() => { if (!execution?.turns) return 0; return execution.turns.reduce((sum, t) => sum + t.duration_ms, 0); }, [execution]); return (
{formatMessage({ id: 'cli-manager.executionDetails' })} {execution && (
{execution.tool.toUpperCase()} {execution.mode && {execution.mode}} {formatDuration(totalDuration)}
)}
{execution && (
{new Date(execution.created_at).toLocaleString()} {execution.id.slice(0, 8)} {execution.turn_count} {formatMessage({ id: 'cli-manager.streamPanel.turns' })}
)}
{isLoading ? (
{formatMessage({ id: 'cli-manager.streamPanel.loading' })}
) : execution?.turns && execution.turns.length > 0 ? ( <> {/* View Toggle - Show for all conversations */}
{execution.turns.length > 1 && ( )}
{/* Content */}
{viewMode === 'per-turn' ? ( ) : viewMode === 'concatenated' ? ( ) : viewMode === 'native' ? ( nativeLoading ? (
Loading native session...
) : nativeSession ? ( ) : (
No native session data available
) ) : null}
{/* Footer Actions */}
{execution.turns.length > 1 && viewMode === 'concatenated' && ( )}
) : (
{formatMessage({ id: 'cli-manager.streamPanel.noDetails' })}
)}
); }